diff --git a/soulseek-rs/docs/openrpc.json b/soulseek-rs/docs/openrpc.json index 213bfc7..46e5886 100644 --- a/soulseek-rs/docs/openrpc.json +++ b/soulseek-rs/docs/openrpc.json @@ -1216,6 +1216,21 @@ "shared_folders" ], "type": "object" + }, + "Watched": { + "description": "Everyone this session is watching, sorted by name.", + "properties": { + "users": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "users" + ], + "type": "object" } } }, @@ -1875,6 +1890,58 @@ }, "summary": "What the server has said about a user so far." }, + { + "name": "user.watch", + "paramStructure": "by-name", + "params": [ + { + "name": "username", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Watch a user, so the server keeps pushing their status." + }, + { + "name": "user.unwatch", + "paramStructure": "by-name", + "params": [ + { + "name": "username", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Stop watching a user." + }, + { + "name": "user.watched", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Watched" + } + }, + "summary": "Everyone this session is watching." + }, { "name": "shares.status", "paramStructure": "by-name", diff --git a/soulseek-rs/src/SKILL.md b/soulseek-rs/src/SKILL.md index 44594b3..2ec67ae 100644 --- a/soulseek-rs/src/SKILL.md +++ b/soulseek-rs/src/SKILL.md @@ -133,6 +133,13 @@ wishlist later rather than repeating the same search. be `null` when the server has not answered that half — `null` means unknown, not offline. +**`watch add `** / **`watch remove `** — prints nothing; exit 0 is +the confirmation. **`watch list`** — one object per watched user: `user`, +`status`. A watch lasts only as long as the session holding it, so these are +worth using against a running daemon (`--daemon`), where the connection stays +up and keeps receiving the server's status pushes. A one-shot run's watch ends +with the run. + **`whoami`** — one object: `user`, `server`, `listening`, `listen_port`, `shared_folders`, `shared_files`, `download_dir`, `privilege_seconds`. The cheapest way to prove the credentials and connection work. diff --git a/soulseek-rs/src/api.rs b/soulseek-rs/src/api.rs index 4006a84..bd39684 100644 --- a/soulseek-rs/src/api.rs +++ b/soulseek-rs/src/api.rs @@ -140,6 +140,12 @@ pub trait SessionApi: Send + Sync { fn request_user_info(&self, username: &str) -> Result<()>; fn user_info(&self, username: &str) -> Option; + /// Ask the server to keep pushing `username`'s status until unwatched. + fn watch_user(&self, username: &str) -> Result<()>; + fn unwatch_user(&self, username: &str) -> Result<()>; + /// Everyone this session is watching, sorted by name. + fn watched_users(&self) -> Vec; + /// The conversation this session already knows about. /// /// Empty locally, where the TUI's own snapshot on disk is the record. A @@ -364,6 +370,18 @@ impl SessionApi for Client { Self::user_info(self, username) } + fn watch_user(&self, username: &str) -> Result<()> { + Self::watch_user(self, username) + } + + fn unwatch_user(&self, username: &str) -> Result<()> { + Self::unwatch_user(self, username) + } + + fn watched_users(&self) -> Vec { + Self::watched_users(self) + } + fn shared_counts(&self) -> (u32, u32) { Self::shared_counts(self) } diff --git a/soulseek-rs/src/cli.rs b/soulseek-rs/src/cli.rs index 7b3f90c..e7ae4d6 100644 --- a/soulseek-rs/src/cli.rs +++ b/soulseek-rs/src/cli.rs @@ -207,6 +207,10 @@ pub enum Commands { /// What the server knows about another user User(UserArgs), + /// Keep a daemon updated about users you care about + #[command(subcommand)] + Watch(WatchCommand), + /// Confirm the credentials and connection work Whoami, @@ -571,6 +575,26 @@ pub struct WishRunArgs { pub sort: SortKey, } +/// Watching only lasts as long as the session doing it, so these are aimed at +/// a running daemon: it stays connected and keeps receiving the status pushes. +#[derive(Subcommand, Debug)] +pub enum WatchCommand { + /// Start watching a user + Add { + /// The user to watch + user: String, + }, + + /// Stop watching a user + Remove { + /// The user to stop watching + user: String, + }, + + /// List the users being watched + List, +} + #[derive(Subcommand, Debug)] pub enum RoomCommand { /// List public rooms and their user counts diff --git a/soulseek-rs/src/commands/mod.rs b/soulseek-rs/src/commands/mod.rs index 28161f7..f678aae 100644 --- a/soulseek-rs/src/commands/mod.rs +++ b/soulseek-rs/src/commands/mod.rs @@ -309,6 +309,7 @@ fn dispatch(ctx: &Ctx, command: Commands) -> CliResult { social::message_read(ctx, listen_span(duration, follow)) } Commands::User(args) => peer::user(ctx, &args), + Commands::Watch(command) => peer::watch(ctx, &command), Commands::Whoami => peer::whoami(ctx), // main.rs runs these itself: some need no account, the rest need the // config store in scope. The arms only keep the match exhaustive. diff --git a/soulseek-rs/src/commands/peer.rs b/soulseek-rs/src/commands/peer.rs index 4101175..ba6218c 100644 --- a/soulseek-rs/src/commands/peer.rs +++ b/soulseek-rs/src/commands/peer.rs @@ -1,9 +1,10 @@ //! Being a peer on the network: `serve`, `whoami`, and `user`. use super::{Ctx, Session}; -use crate::cli::{ServeArgs, UserArgs}; +use crate::cli::{ServeArgs, UserArgs, WatchCommand}; use crate::output::{ - CliError, CliResult, UploadRecord, UserRecord, WhoamiRecord, + CliError, CliResult, UploadRecord, UserRecord, WatchedUserRecord, + WhoamiRecord, }; use soulseek_rs::types::{UploadInfo, UploadStatus, UserInfo}; use std::collections::HashMap; @@ -94,6 +95,40 @@ pub fn user(ctx: &Ctx, args: &UserArgs) -> CliResult { Ok(()) } +/// Start or stop watching a user, or list who is being watched. +/// +/// A watch lives in the session that made it, so `add` against a one-shot CLI +/// run ends with that run. Pointed at a daemon it persists for that daemon's +/// lifetime, which is the point of the command. +pub fn watch(ctx: &Ctx, command: &WatchCommand) -> CliResult { + let session = Session::open(ctx)?; + match command { + WatchCommand::Add { user } => { + session.client.watch_user(user).map_err(|e| { + CliError::connection(format!("cannot watch {user}: {e}")) + })?; + ctx.out.status(&format!("watching {user}")); + } + WatchCommand::Remove { user } => { + session.client.unwatch_user(user).map_err(|e| { + CliError::connection(format!("cannot unwatch {user}: {e}")) + })?; + ctx.out.status(&format!("no longer watching {user}")); + } + WatchCommand::List => { + for user in session.client.watched_users() { + let status = session + .client + .user_info(&user) + .and_then(|info| info.presence) + .map(|p| p.status.to_string()); + ctx.out.emit(&WatchedUserRecord { user, status }); + } + } + } + Ok(()) +} + /// Stay online sharing files, and — with `--wishlist` — re-run the standing /// searches on the interval the server sets while doing so. pub fn serve( diff --git a/soulseek-rs/src/daemon/openrpc.rs b/soulseek-rs/src/daemon/openrpc.rs index e58b9c4..a489826 100644 --- a/soulseek-rs/src/daemon/openrpc.rs +++ b/soulseek-rs/src/daemon/openrpc.rs @@ -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::(generator), Method::MessageSend => schema_of::(generator), - Method::BrowseUser | Method::UserRequest | Method::UserInfoOf => { - schema_of::(generator) - } + Method::BrowseUser + | Method::UserRequest + | Method::UserInfoOf + | Method::UserWatch + | Method::UserUnwatch => schema_of::(generator), Method::SharesSet => schema_of::(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::(generator), Method::PrivilegesOwn => schema_of::(generator), Method::RoomMembers => schema_of::(generator), + Method::UserWatched => schema_of::(generator), Method::MessageHistory => schema_of::(generator), Method::UserInfoOf => schema_of::(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::(generator), + | Method::UserRequest + | Method::UserWatch + | Method::UserUnwatch => schema_of::(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." diff --git a/soulseek-rs/src/daemon/proto/method.rs b/soulseek-rs/src/daemon/proto/method.rs index 96a69c2..3b71247 100644 --- a/soulseek-rs/src/daemon/proto/method.rs +++ b/soulseek-rs/src/daemon/proto/method.rs @@ -139,6 +139,13 @@ pub struct Members { pub users: Vec, } +/// 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, +} + /// 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. diff --git a/soulseek-rs/src/daemon/proto/mod.rs b/soulseek-rs/src/daemon/proto/mod.rs index f91b83c..7bd337a 100644 --- a/soulseek-rs/src/daemon/proto/mod.rs +++ b/soulseek-rs/src/daemon/proto/mod.rs @@ -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", diff --git a/soulseek-rs/src/daemon/server/mod.rs b/soulseek-rs/src/daemon/server/mod.rs index 718a23a..0bd8470 100644 --- a/soulseek-rs/src/daemon/server/mod.rs +++ b/soulseek-rs/src/daemon/server/mod.rs @@ -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>, + } 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 { + self.watched.lock().expect("watch list").clone() + } fn user_info(&self, _username: &str) -> Option { None } diff --git a/soulseek-rs/src/output.rs b/soulseek-rs/src/output.rs index d50a853..d8fe980 100644 --- a/soulseek-rs/src/output.rs +++ b/soulseek-rs/src/output.rs @@ -383,6 +383,25 @@ impl Record for RoomMemberRecord { } } +/// One user this session is watching, with whatever the server has already +/// said about them. The status is `None` until the watch reply lands. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct WatchedUserRecord { + pub user: String, + /// `online`, `away`, or `offline`. + pub status: Option, +} + +impl Record for WatchedUserRecord { + fn text(&self) -> String { + format!( + "{}\t{}", + sanitize(&self.user), + self.status.clone().unwrap_or_else(|| "-".to_string()) + ) + } +} + /// One configured share folder. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ShareRecord { diff --git a/soulseek-rs/src/remote/mod.rs b/soulseek-rs/src/remote/mod.rs index e236d05..24277f8 100644 --- a/soulseek-rs/src/remote/mod.rs +++ b/soulseek-rs/src/remote/mod.rs @@ -15,7 +15,7 @@ use crate::daemon::proto::{ Members, MessageParams, Messages, Method, PROTOCOL_VERSION, QueryParams, Request, Response, RoomRef, RpcError, SayParams, SearchResults, Searches, Seconds, SharesStatus, SlotsParams, TransferRef, Uploads, UserRef, - UserResult, + UserResult, Watched, }; use mirror::Mirror; use serde::de::DeserializeOwned; @@ -757,6 +757,30 @@ impl SessionApi for RemoteSession { ) } + fn watch_user(&self, username: &str) -> Result<()> { + self.tell( + Method::UserWatch, + UserRef { + username: username.to_string(), + }, + ) + } + + fn unwatch_user(&self, username: &str) -> Result<()> { + self.tell( + Method::UserUnwatch, + UserRef { + username: username.to_string(), + }, + ) + } + + fn watched_users(&self) -> Vec { + self.request::<_, Watched>(Method::UserWatched, ()) + .map(|watched| watched.users) + .unwrap_or_default() + } + fn user_info(&self, username: &str) -> Option { self.request::<_, UserResult>( Method::UserInfoOf, diff --git a/soulseek-rs/src/ui/main_tui/mod.rs b/soulseek-rs/src/ui/main_tui/mod.rs index 0b80188..1fe8624 100644 --- a/soulseek-rs/src/ui/main_tui/mod.rs +++ b/soulseek-rs/src/ui/main_tui/mod.rs @@ -489,6 +489,15 @@ mod tests { ) -> soulseek_rs::Result<()> { Ok(()) } + fn watch_user(&self, _username: &str) -> soulseek_rs::Result<()> { + Ok(()) + } + fn unwatch_user(&self, _username: &str) -> soulseek_rs::Result<()> { + Ok(()) + } + fn watched_users(&self) -> Vec { + Vec::new() + } fn user_info(&self, _username: &str) -> Option { None }