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
260 changes: 259 additions & 1 deletion src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,48 @@ impl MisskeyClient {
Ok(antennas)
}

/// 単一アンテナの設定を取得する (antennas/show)。
pub async fn get_antenna(
&self,
host: &str,
token: &str,
antenna_id: &str,
) -> Result<Antenna, NoteDeckError> {
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<Antenna, NoteDeckError> {
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,
Expand Down Expand Up @@ -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<RawNote> = serde_json::from_value(data)?;
Ok(raw
Expand Down Expand Up @@ -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<bool>,
) -> 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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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));
}
}
49 changes: 48 additions & 1 deletion src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,15 @@ pub struct NormalizedUserDetail {
pub followers_visibility: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub followed_message: Option<String>,
/// このユーザーに対する自分用メモ (users/update-memo)
#[serde(skip_serializing_if = "Option::is_none")]
pub memo: Option<String>,
/// フォロー中のみ意味を持つ: 'normal' | 'none' (投稿通知)
#[serde(skip_serializing_if = "Option::is_none")]
pub notify: Option<String>,
/// フォロー中のみ意味を持つ: TL に他者への返信を含めるか
#[serde(skip_serializing_if = "Option::is_none")]
pub with_replies: Option<bool>,
}

#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
Expand Down Expand Up @@ -495,12 +504,36 @@ pub struct UserList {
pub liked_count: Option<i64>,
}

#[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<String>,
/// ソースが 'users' / 'users_blacklist' のときの対象 (["@user@host", ...])
#[serde(default)]
pub users: Vec<String>,
#[serde(default)]
pub keywords: Vec<Vec<String>>,
#[serde(default)]
pub exclude_keywords: Vec<Vec<String>>,
#[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,
}

// =============================================================================
Expand Down Expand Up @@ -940,6 +973,9 @@ pub struct SearchOptions {
pub until_id: Option<String>,
pub since_date: Option<i64>,
pub until_date: Option<i64>,
/// 指定ユーザーのノートのみに絞る (notes/search の userId)
#[serde(default)]
pub user_id: Option<String>,
}

impl SearchOptions {
Expand All @@ -950,6 +986,7 @@ impl SearchOptions {
until_id: None,
since_date: None,
until_date: None,
user_id: None,
}
}

Expand All @@ -966,6 +1003,7 @@ impl Default for SearchOptions {
until_id: None,
since_date: None,
until_date: None,
user_id: None,
}
}
}
Expand Down Expand Up @@ -1173,6 +1211,12 @@ pub struct RawUserDetail {
#[serde(default)]
pub followers_visibility: Option<String>,
pub followed_message: Option<String>,
#[serde(default)]
pub memo: Option<String>,
#[serde(default)]
pub notify: Option<String>,
#[serde(default)]
pub with_replies: Option<bool>,
}

#[derive(Debug, Deserialize)]
Expand Down Expand Up @@ -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,
}
}
}
Expand Down
Loading