diff --git a/src/api.rs b/src/api.rs index 8816c06..6a4da7d 100644 --- a/src/api.rs +++ b/src/api.rs @@ -242,6 +242,48 @@ impl MisskeyClient { Ok(antennas) } + /// 単一アンテナの設定を取得する (antennas/show)。 + pub async fn get_antenna( + &self, + host: &str, + token: &str, + antenna_id: &str, + ) -> Result { + let data = self + .request(host, token, "antennas/show", json!({ "antennaId": antenna_id })) + .await?; + let antenna: Antenna = serde_json::from_value(data)?; + Ok(antenna) + } + + /// アンテナ設定を更新する (antennas/update)。 + /// 本家 API は全フィールドを要求するため、変更済みの `Antenna` をそのまま往復させる。 + pub async fn update_antenna( + &self, + host: &str, + token: &str, + antenna: &Antenna, + ) -> Result { + let body = json!({ + "antennaId": antenna.id, + "name": antenna.name, + "src": antenna.src, + "userListId": antenna.user_list_id, + "users": antenna.users, + "keywords": antenna.keywords, + "excludeKeywords": antenna.exclude_keywords, + "caseSensitive": antenna.case_sensitive, + "localOnly": antenna.local_only, + "excludeBots": antenna.exclude_bots, + "withReplies": antenna.with_replies, + "withFile": antenna.with_file, + "notify": antenna.notify, + }); + let data = self.request(host, token, "antennas/update", body).await?; + let updated: Antenna = serde_json::from_value(data)?; + Ok(updated) + } + #[allow(clippy::too_many_arguments)] pub async fn get_antenna_notes( &self, @@ -814,6 +856,9 @@ impl MisskeyClient { if let Some(d) = options.until_date { params["untilDate"] = json!(d); } + if let Some(ref uid) = options.user_id { + params["userId"] = json!(uid); + } let data = self.request(host, token, "notes/search", params).await?; let raw: Vec = serde_json::from_value(data)?; Ok(raw @@ -1134,6 +1179,47 @@ impl MisskeyClient { Ok(()) } + /// フォロー設定を更新する (following/update)。 + /// `notify` は 'normal' | 'none'、`with_replies` は TL に他者宛て返信を含めるか。 + /// いずれも指定されたものだけを送信する。 + pub async fn update_following( + &self, + host: &str, + token: &str, + user_id: &str, + notify: Option<&str>, + with_replies: Option, + ) -> Result<(), NoteDeckError> { + let mut body = json!({ "userId": user_id }); + if let Some(n) = notify { + body["notify"] = json!(n); + } + if let Some(w) = with_replies { + body["withReplies"] = json!(w); + } + self.request(host, token, "following/update", body).await?; + Ok(()) + } + + /// このユーザーに対する自分用メモを更新する (users/update-memo)。 + /// 空文字を渡すとメモが削除される (本家挙動準拠)。 + pub async fn update_user_memo( + &self, + host: &str, + token: &str, + user_id: &str, + memo: &str, + ) -> Result<(), NoteDeckError> { + self.request( + host, + token, + "users/update-memo", + json!({ "userId": user_id, "memo": memo }), + ) + .await?; + Ok(()) + } + pub async fn accept_follow_request( &self, host: &str, @@ -2366,7 +2452,7 @@ impl MisskeyClient { #[cfg(test)] mod tests { use super::*; - use wiremock::matchers::{method, path}; + use wiremock::matchers::{body_partial_json, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; fn raw_note_json(id: &str, text: &str) -> Value { @@ -3272,4 +3358,176 @@ mod tests { // 両方失敗 → false (panic せず正常 return) assert!(!unread); } + + #[tokio::test] + async fn update_following_sends_notify_and_with_replies() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/following/update")) + .and(body_partial_json( + json!({ "userId": "u1", "notify": "none", "withReplies": true }), + )) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) + .mount(&server) + .await; + + let client = MisskeyClient::with_base_url(&server.uri()); + client + .update_following("h", "token", "u1", Some("none"), Some(true)) + .await + .unwrap(); + } + + #[tokio::test] + async fn update_following_omits_unset_fields() { + let server = MockServer::start().await; + // withReplies のみ指定 → notify は body に含まれないこと + Mock::given(method("POST")) + .and(path("/api/following/update")) + .and(body_partial_json(json!({ "userId": "u1", "withReplies": false }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) + .mount(&server) + .await; + + let client = MisskeyClient::with_base_url(&server.uri()); + client + .update_following("h", "token", "u1", None, Some(false)) + .await + .unwrap(); + } + + #[tokio::test] + async fn update_user_memo_sends_memo() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/users/update-memo")) + .and(body_partial_json(json!({ "userId": "u1", "memo": "friend" }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) + .mount(&server) + .await; + + let client = MisskeyClient::with_base_url(&server.uri()); + client + .update_user_memo("h", "token", "u1", "friend") + .await + .unwrap(); + } + + #[tokio::test] + async fn get_antenna_parses_full_entity() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/antennas/show")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "a1", + "name": "friends", + "src": "users", + "users": ["@alice@example.com"], + "keywords": [], + "excludeKeywords": [], + "caseSensitive": false, + "localOnly": false, + "excludeBots": false, + "withReplies": false, + "withFile": false, + "notify": false + }))) + .mount(&server) + .await; + + let client = MisskeyClient::with_base_url(&server.uri()); + let antenna = client.get_antenna("h", "token", "a1").await.unwrap(); + assert_eq!(antenna.src, "users"); + assert_eq!(antenna.users, vec!["@alice@example.com".to_string()]); + } + + #[tokio::test] + async fn update_antenna_round_trips_users() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/antennas/update")) + .and(body_partial_json(json!({ + "antennaId": "a1", + "src": "users", + "users": ["@alice@example.com", "@bob@example.com"] + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "a1", + "name": "friends", + "src": "users", + "users": ["@alice@example.com", "@bob@example.com"] + }))) + .mount(&server) + .await; + + let antenna = Antenna { + id: "a1".to_string(), + name: "friends".to_string(), + src: "users".to_string(), + user_list_id: None, + users: vec![ + "@alice@example.com".to_string(), + "@bob@example.com".to_string(), + ], + keywords: vec![], + exclude_keywords: vec![], + case_sensitive: false, + local_only: false, + exclude_bots: false, + with_replies: false, + with_file: false, + notify: false, + }; + let client = MisskeyClient::with_base_url(&server.uri()); + let updated = client.update_antenna("h", "token", &antenna).await.unwrap(); + assert_eq!(updated.users.len(), 2); + } + + #[tokio::test] + async fn search_notes_sends_user_id_filter() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/notes/search")) + .and(body_partial_json(json!({ "query": "rust", "userId": "u1" }))) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(json!([raw_note_json("n1", "rust note")])), + ) + .mount(&server) + .await; + + let mut options = SearchOptions::default(); + options.user_id = Some("u1".to_string()); + let client = MisskeyClient::with_base_url(&server.uri()); + let notes = client + .search_notes("h", "token", "acc1", "rust", options) + .await + .unwrap(); + assert_eq!(notes.len(), 1); + } + + #[tokio::test] + async fn get_user_detail_parses_memo_notify_with_replies() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/users/show")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "u1", + "username": "taka", + "host": null, + "name": "Taka", + "createdAt": "2024-01-01T00:00:00.000Z", + "memo": "my note", + "notify": "normal", + "withReplies": true + }))) + .mount(&server) + .await; + + let client = MisskeyClient::with_base_url(&server.uri()); + let user = client.get_user_detail("h", "token", "u1").await.unwrap(); + assert_eq!(user.memo.as_deref(), Some("my note")); + assert_eq!(user.notify.as_deref(), Some("normal")); + assert_eq!(user.with_replies, Some(true)); + } } diff --git a/src/models.rs b/src/models.rs index ec39145..295d9a1 100644 --- a/src/models.rs +++ b/src/models.rs @@ -279,6 +279,15 @@ pub struct NormalizedUserDetail { pub followers_visibility: Option, #[serde(skip_serializing_if = "Option::is_none")] pub followed_message: Option, + /// このユーザーに対する自分用メモ (users/update-memo) + #[serde(skip_serializing_if = "Option::is_none")] + pub memo: Option, + /// フォロー中のみ意味を持つ: 'normal' | 'none' (投稿通知) + #[serde(skip_serializing_if = "Option::is_none")] + pub notify: Option, + /// フォロー中のみ意味を持つ: TL に他者への返信を含めるか + #[serde(skip_serializing_if = "Option::is_none")] + pub with_replies: Option, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] @@ -495,12 +504,36 @@ pub struct UserList { pub liked_count: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[cfg_attr(feature = "specta", derive(specta::Type))] #[serde(rename_all = "camelCase")] pub struct Antenna { pub id: String, pub name: String, + /// 'home' | 'all' | 'users' | 'list' | 'users_blacklist' + #[serde(default)] + pub src: String, + #[serde(default)] + pub user_list_id: Option, + /// ソースが 'users' / 'users_blacklist' のときの対象 (["@user@host", ...]) + #[serde(default)] + pub users: Vec, + #[serde(default)] + pub keywords: Vec>, + #[serde(default)] + pub exclude_keywords: Vec>, + #[serde(default)] + pub case_sensitive: bool, + #[serde(default)] + pub local_only: bool, + #[serde(default)] + pub exclude_bots: bool, + #[serde(default)] + pub with_replies: bool, + #[serde(default)] + pub with_file: bool, + #[serde(default)] + pub notify: bool, } // ============================================================================= @@ -940,6 +973,9 @@ pub struct SearchOptions { pub until_id: Option, pub since_date: Option, pub until_date: Option, + /// 指定ユーザーのノートのみに絞る (notes/search の userId) + #[serde(default)] + pub user_id: Option, } impl SearchOptions { @@ -950,6 +986,7 @@ impl SearchOptions { until_id: None, since_date: None, until_date: None, + user_id: None, } } @@ -966,6 +1003,7 @@ impl Default for SearchOptions { until_id: None, since_date: None, until_date: None, + user_id: None, } } } @@ -1173,6 +1211,12 @@ pub struct RawUserDetail { #[serde(default)] pub followers_visibility: Option, pub followed_message: Option, + #[serde(default)] + pub memo: Option, + #[serde(default)] + pub notify: Option, + #[serde(default)] + pub with_replies: Option, } #[derive(Debug, Deserialize)] @@ -1397,6 +1441,9 @@ impl RawUserDetail { following_visibility: self.following_visibility, followers_visibility: self.followers_visibility, followed_message: self.followed_message, + memo: self.memo, + notify: self.notify, + with_replies: self.with_replies, } } }