diff --git a/README.md b/README.md index 414d142..efce70e 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Three methods, resolved in priority order: ax [--output json|plain|markdown|human] [--verbose] ├── tweet post [--community-id ID]|get|delete|reply|quote|search|metrics ├── user get|timeline|followers|following -├── self mentions|bookmarks|like|unlike|retweet|unretweet|bookmark|unbookmark +├── self mentions|bookmarks|like|unlike|retweet|unretweet|bookmark|unbookmark|follow|unfollow ├── community search|get|post └── auth login [--no-browser]|callback|status|logout ``` diff --git a/skills/agent-x/SKILL.md b/skills/agent-x/SKILL.md index f5a269e..e20c219 100644 --- a/skills/agent-x/SKILL.md +++ b/skills/agent-x/SKILL.md @@ -133,7 +133,7 @@ Check status: `ax auth status` | Log out: `ax auth logout` ax [--output json|plain|markdown|human] [--verbose] ├── tweet post [--community-id ID]|get|delete|reply|quote|search|metrics ├── user get|timeline|followers|following -├── self mentions|bookmarks|like|unlike|retweet|unretweet|bookmark|unbookmark +├── self mentions|bookmarks|like|unlike|retweet|unretweet|bookmark|unbookmark|follow|unfollow ├── community search|get|post └── auth login [--no-browser]|callback|status|logout ``` diff --git a/src/api/self_ops.rs b/src/api/self_ops.rs index 3ae1220..e18f6ca 100644 --- a/src/api/self_ops.rs +++ b/src/api/self_ops.rs @@ -163,4 +163,42 @@ impl XClient { id: Some(tweet_id.to_string()), }) } + + pub async fn follow_user(&self, username: &str) -> Result { + let me = self.get_me_id().await?; + let target_id = self.resolve_user_id(username).await?; + let body = serde_json::json!({ "target_user_id": target_id }); + let resp = self.post(&format!("/users/{me}/following"), body).await?; + let val: serde_json::Value = resp.json().await?; + let following = val + .get("data") + .and_then(|d| d.get("following")) + .and_then(|f| f.as_bool()) + .unwrap_or(false); + Ok(MutationResult { + action: "follow".to_string(), + success: following, + id: Some(target_id), + }) + } + + pub async fn unfollow_user(&self, username: &str) -> Result { + let me = self.get_me_id().await?; + let target_id = self.resolve_user_id(username).await?; + let resp = self + .delete(&format!("/users/{me}/following/{target_id}")) + .await?; + let val: serde_json::Value = resp.json().await?; + let unfollowed = val + .get("data") + .and_then(|d| d.get("following")) + .and_then(|f| f.as_bool()) + .map(|f| !f) + .unwrap_or(true); + Ok(MutationResult { + action: "unfollow".to_string(), + success: unfollowed, + id: Some(target_id), + }) + } } diff --git a/src/api/users.rs b/src/api/users.rs index 1f7a880..1338d4a 100644 --- a/src/api/users.rs +++ b/src/api/users.rs @@ -18,7 +18,7 @@ impl XClient { } /// Resolve a username to a user ID. If the input is already numeric, return it as-is. - async fn resolve_user_id(&self, user: &str) -> Result { + pub async fn resolve_user_id(&self, user: &str) -> Result { if user.chars().all(|c| c.is_ascii_digit()) { Ok(user.to_string()) } else { diff --git a/src/cli/mod.rs b/src/cli/mod.rs index c5436ba..69e5690 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -35,7 +35,7 @@ pub enum Command { #[command(subcommand)] action: user::UserAction, }, - /// Self-account operations (mentions, bookmarks, likes, retweets) + /// Self-account operations (mentions, bookmarks, likes, retweets, follow, unfollow) #[command(name = "self")] SelfOps { #[command(subcommand)] diff --git a/src/cli/self_ops.rs b/src/cli/self_ops.rs index e18b954..2e917c1 100644 --- a/src/cli/self_ops.rs +++ b/src/cli/self_ops.rs @@ -48,4 +48,14 @@ pub enum SelfAction { /// Tweet ID id: String, }, + /// Follow a user + Follow { + /// Username (with or without @) + username: String, + }, + /// Unfollow a user + Unfollow { + /// Username (with or without @) + username: String, + }, } diff --git a/src/main.rs b/src/main.rs index 62ef587..d93dab3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -321,6 +321,14 @@ async fn handle_self( let result = client.unbookmark_tweet(&id).await?; print_output(&result, config.output_mode); } + SelfAction::Follow { username } => { + let result = client.follow_user(&username).await?; + print_output(&result, config.output_mode); + } + SelfAction::Unfollow { username } => { + let result = client.unfollow_user(&username).await?; + print_output(&result, config.output_mode); + } } Ok(()) } diff --git a/tests/cli_test.rs b/tests/cli_test.rs index 8355e4d..fe4d5bd 100644 --- a/tests/cli_test.rs +++ b/tests/cli_test.rs @@ -222,3 +222,44 @@ fn test_community_get_no_auth() { .failure() .code(2); } + +#[test] +fn test_self_help_includes_follow() { + Command::cargo_bin("ax") + .unwrap() + .args(["self", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("follow")) + .stdout(predicate::str::contains("unfollow")); +} + +#[test] +fn test_self_follow_no_auth() { + Command::cargo_bin("ax") + .unwrap() + .args(["self", "follow", "testuser"]) + .env_remove("X_BEARER_TOKEN") + .env_remove("X_API_KEY") + .env_remove("X_API_SECRET") + .env_remove("X_ACCESS_TOKEN") + .env_remove("X_ACCESS_TOKEN_SECRET") + .assert() + .failure() + .code(2); +} + +#[test] +fn test_self_unfollow_no_auth() { + Command::cargo_bin("ax") + .unwrap() + .args(["self", "unfollow", "testuser"]) + .env_remove("X_BEARER_TOKEN") + .env_remove("X_API_KEY") + .env_remove("X_API_SECRET") + .env_remove("X_ACCESS_TOKEN") + .env_remove("X_ACCESS_TOKEN_SECRET") + .assert() + .failure() + .code(2); +}