//! Twitter Hand - Twitter/X automation capabilities //! //! This hand provides Twitter/X automation features: //! - Post tweets //! - Get timeline //! - Search tweets //! - Manage followers //! //! Note: Requires Twitter API credentials (API Key, API Secret, Access Token, Access Secret) use async_trait::async_trait; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::sync::Arc; use tokio::sync::RwLock; use zclaw_types::Result; use crate::{Hand, HandConfig, HandContext, HandResult}; /// Twitter credentials #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TwitterCredentials { /// API Key (Consumer Key) pub api_key: String, /// API Secret (Consumer Secret) pub api_secret: String, /// Access Token pub access_token: String, /// Access Token Secret pub access_token_secret: String, /// Bearer Token (for API v2) #[serde(default)] pub bearer_token: Option, } /// Tweet configuration #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TweetConfig { /// Tweet text pub text: String, /// Media URLs to attach #[serde(default)] pub media_urls: Vec, /// Reply to tweet ID #[serde(default)] pub reply_to: Option, /// Quote tweet ID #[serde(default)] pub quote_tweet: Option, /// Poll configuration #[serde(default)] pub poll: Option, } /// Poll configuration #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PollConfig { pub options: Vec, pub duration_minutes: u32, } /// Tweet search configuration #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SearchConfig { /// Search query pub query: String, /// Maximum results #[serde(default = "default_search_max")] pub max_results: u32, /// Next page token #[serde(default)] pub next_token: Option, } fn default_search_max() -> u32 { 10 } /// Timeline configuration #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TimelineConfig { /// User ID (optional, defaults to authenticated user) #[serde(default)] pub user_id: Option, /// Maximum results #[serde(default = "default_timeline_max")] pub max_results: u32, /// Exclude replies #[serde(default)] pub exclude_replies: bool, /// Include retweets #[serde(default = "default_include_retweets")] pub include_retweets: bool, } fn default_timeline_max() -> u32 { 10 } fn default_include_retweets() -> bool { true } /// Tweet data #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Tweet { pub id: String, pub text: String, pub author_id: String, pub author_name: String, pub author_username: String, pub created_at: String, pub public_metrics: TweetMetrics, #[serde(default)] pub media: Vec, } /// Tweet metrics #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TweetMetrics { pub retweet_count: u32, pub reply_count: u32, pub like_count: u32, pub quote_count: u32, pub impression_count: Option, } /// Media info #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MediaInfo { pub media_key: String, pub media_type: String, pub url: String, pub width: u32, pub height: u32, } /// User data #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TwitterUser { pub id: String, pub name: String, pub username: String, pub description: Option, pub profile_image_url: Option, pub location: Option, pub url: Option, pub verified: bool, pub public_metrics: UserMetrics, } /// User metrics #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UserMetrics { pub followers_count: u32, pub following_count: u32, pub tweet_count: u32, pub listed_count: u32, } /// Twitter action types #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "action")] pub enum TwitterAction { #[serde(rename = "tweet")] Tweet { config: TweetConfig }, #[serde(rename = "delete_tweet")] DeleteTweet { tweet_id: String }, #[serde(rename = "retweet")] Retweet { tweet_id: String }, #[serde(rename = "unretweet")] Unretweet { tweet_id: String }, #[serde(rename = "like")] Like { tweet_id: String }, #[serde(rename = "unlike")] Unlike { tweet_id: String }, #[serde(rename = "search")] Search { config: SearchConfig }, #[serde(rename = "timeline")] Timeline { config: TimelineConfig }, #[serde(rename = "get_tweet")] GetTweet { tweet_id: String }, #[serde(rename = "get_user")] GetUser { username: String }, #[serde(rename = "followers")] Followers { user_id: String, max_results: Option }, #[serde(rename = "following")] Following { user_id: String, max_results: Option }, #[serde(rename = "check_credentials")] CheckCredentials, } /// Twitter Hand implementation pub struct TwitterHand { config: HandConfig, credentials: Arc>>, } impl TwitterHand { /// Create a new Twitter hand pub fn new() -> Self { Self { config: HandConfig { id: "twitter".to_string(), name: "Twitter 自动化".to_string(), description: "Twitter/X 自动化能力,发布、搜索和管理内容".to_string(), needs_approval: true, // Twitter actions need approval dependencies: vec!["twitter_api_key".to_string()], input_schema: Some(serde_json::json!({ "type": "object", "oneOf": [ { "properties": { "action": { "const": "tweet" }, "config": { "type": "object", "properties": { "text": { "type": "string", "maxLength": 280 }, "mediaUrls": { "type": "array", "items": { "type": "string" } }, "replyTo": { "type": "string" }, "quoteTweet": { "type": "string" } }, "required": ["text"] } }, "required": ["action", "config"] }, { "properties": { "action": { "const": "search" }, "config": { "type": "object", "properties": { "query": { "type": "string" }, "maxResults": { "type": "integer" } }, "required": ["query"] } }, "required": ["action", "config"] }, { "properties": { "action": { "const": "timeline" }, "config": { "type": "object", "properties": { "userId": { "type": "string" }, "maxResults": { "type": "integer" } } } }, "required": ["action"] }, { "properties": { "action": { "const": "get_tweet" }, "tweetId": { "type": "string" } }, "required": ["action", "tweetId"] }, { "properties": { "action": { "const": "check_credentials" } }, "required": ["action"] } ] })), tags: vec!["twitter".to_string(), "social".to_string(), "automation".to_string(), "demo".to_string()], enabled: true, max_concurrent: 0, timeout_secs: 0, }, credentials: Arc::new(RwLock::new(None)), } } /// Set credentials pub async fn set_credentials(&self, creds: TwitterCredentials) { let mut c = self.credentials.write().await; *c = Some(creds); } /// Get credentials async fn get_credentials(&self) -> Option { let c = self.credentials.read().await; c.clone() } /// Execute tweet action — POST /2/tweets async fn execute_tweet(&self, config: &TweetConfig) -> Result { let creds = self.get_credentials().await .ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?; let client = reqwest::Client::new(); let body = json!({ "text": config.text }); let response = client.post("https://api.twitter.com/2/tweets") .header("Authorization", format!("Bearer {}", creds.bearer_token.as_deref().unwrap_or(""))) .header("Content-Type", "application/json") .header("User-Agent", "ZCLAW/1.0") .json(&body) .send() .await .map_err(|e| zclaw_types::ZclawError::HandError(format!("Twitter API request failed: {}", e)))?; let status = response.status(); let response_text = response.text().await .map_err(|e| zclaw_types::ZclawError::HandError(format!("Failed to read response: {}", e)))?; if !status.is_success() { tracing::warn!("[TwitterHand] Tweet failed: {} - {}", status, response_text); return Ok(json!({ "success": false, "error": format!("Twitter API returned {}: {}", status, response_text), "status_code": status.as_u16() })); } // Parse the response to extract tweet_id let parsed: Value = serde_json::from_str(&response_text).unwrap_or(json!({"raw": response_text})); Ok(json!({ "success": true, "tweet_id": parsed["data"]["id"].as_str().unwrap_or("unknown"), "text": config.text, "raw_response": parsed, "message": "Tweet posted successfully" })) } /// Execute search action — GET /2/tweets/search/recent async fn execute_search(&self, config: &SearchConfig) -> Result { let creds = self.get_credentials().await .ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?; let client = reqwest::Client::new(); let max = config.max_results.max(10).min(100); let response = client.get("https://api.twitter.com/2/tweets/search/recent") .header("Authorization", format!("Bearer {}", creds.bearer_token.as_deref().unwrap_or(""))) .header("User-Agent", "ZCLAW/1.0") .query(&[ ("query", config.query.as_str()), ("max_results", max.to_string().as_str()), ("tweet.fields", "created_at,author_id,public_metrics,lang"), ]) .send() .await .map_err(|e| zclaw_types::ZclawError::HandError(format!("Twitter search failed: {}", e)))?; let status = response.status(); let response_text = response.text().await .map_err(|e| zclaw_types::ZclawError::HandError(format!("Failed to read response: {}", e)))?; if !status.is_success() { return Ok(json!({ "success": false, "error": format!("Twitter API returned {}: {}", status, response_text), "status_code": status.as_u16() })); } let parsed: Value = serde_json::from_str(&response_text).unwrap_or(json!({"raw": response_text})); Ok(json!({ "success": true, "query": config.query, "tweets": parsed["data"].as_array().cloned().unwrap_or_default(), "meta": parsed["meta"].clone(), "message": "Search completed" })) } /// Execute timeline action — GET /2/users/:id/timelines/reverse_chronological async fn execute_timeline(&self, config: &TimelineConfig) -> Result { let creds = self.get_credentials().await .ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?; let client = reqwest::Client::new(); let user_id = config.user_id.as_deref().unwrap_or("me"); let url = format!("https://api.twitter.com/2/users/{}/timelines/reverse_chronological", user_id); let max = config.max_results.max(5).min(100); let response = client.get(&url) .header("Authorization", format!("Bearer {}", creds.bearer_token.as_deref().unwrap_or(""))) .header("User-Agent", "ZCLAW/1.0") .query(&[ ("max_results", max.to_string().as_str()), ("tweet.fields", "created_at,author_id,public_metrics"), ]) .send() .await .map_err(|e| zclaw_types::ZclawError::HandError(format!("Timeline fetch failed: {}", e)))?; let status = response.status(); let response_text = response.text().await .map_err(|e| zclaw_types::ZclawError::HandError(format!("Failed to read response: {}", e)))?; if !status.is_success() { return Ok(json!({ "success": false, "error": format!("Twitter API returned {}: {}", status, response_text), "status_code": status.as_u16() })); } let parsed: Value = serde_json::from_str(&response_text).unwrap_or(json!({"raw": response_text})); Ok(json!({ "success": true, "user_id": user_id, "tweets": parsed["data"].as_array().cloned().unwrap_or_default(), "meta": parsed["meta"].clone(), "message": "Timeline fetched" })) } /// Get tweet by ID — GET /2/tweets/:id async fn execute_get_tweet(&self, tweet_id: &str) -> Result { let creds = self.get_credentials().await .ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?; let client = reqwest::Client::new(); let url = format!("https://api.twitter.com/2/tweets/{}", tweet_id); let response = client.get(&url) .header("Authorization", format!("Bearer {}", creds.bearer_token.as_deref().unwrap_or(""))) .header("User-Agent", "ZCLAW/1.0") .query(&[("tweet.fields", "created_at,author_id,public_metrics,lang")]) .send() .await .map_err(|e| zclaw_types::ZclawError::HandError(format!("Tweet lookup failed: {}", e)))?; let status = response.status(); let response_text = response.text().await .map_err(|e| zclaw_types::ZclawError::HandError(format!("Failed to read response: {}", e)))?; if !status.is_success() { return Ok(json!({ "success": false, "error": format!("Twitter API returned {}: {}", status, response_text), "status_code": status.as_u16() })); } let parsed: Value = serde_json::from_str(&response_text).unwrap_or(json!({"raw": response_text})); Ok(json!({ "success": true, "tweet_id": tweet_id, "tweet": parsed["data"].clone(), "message": "Tweet fetched" })) } /// Get user by username — GET /2/users/by/username/:username async fn execute_get_user(&self, username: &str) -> Result { let creds = self.get_credentials().await .ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?; let client = reqwest::Client::new(); let url = format!("https://api.twitter.com/2/users/by/username/{}", username); let response = client.get(&url) .header("Authorization", format!("Bearer {}", creds.bearer_token.as_deref().unwrap_or(""))) .header("User-Agent", "ZCLAW/1.0") .query(&[("user.fields", "created_at,description,public_metrics,verified")]) .send() .await .map_err(|e| zclaw_types::ZclawError::HandError(format!("User lookup failed: {}", e)))?; let status = response.status(); let response_text = response.text().await .map_err(|e| zclaw_types::ZclawError::HandError(format!("Failed to read response: {}", e)))?; if !status.is_success() { return Ok(json!({ "success": false, "error": format!("Twitter API returned {}: {}", status, response_text), "status_code": status.as_u16() })); } let parsed: Value = serde_json::from_str(&response_text).unwrap_or(json!({"raw": response_text})); Ok(json!({ "success": true, "username": username, "user": parsed["data"].clone(), "message": "User fetched" })) } /// Execute like action — PUT /2/users/:id/likes /// /// **NOTE**: Twitter API v2 requires OAuth 1.0a user context for like/retweet. /// Bearer token (app-only auth) is not sufficient and will return 403. /// This action is currently unavailable until OAuth 1.0a signing is implemented. async fn execute_like(&self, tweet_id: &str) -> Result { let _ = tweet_id; tracing::warn!("[TwitterHand] like action requires OAuth 1.0a user context — not yet supported"); Ok(json!({ "success": false, "action": "like", "error": "OAuth 1.0a user context required. Like action is not yet supported with app-only Bearer token.", "suggestion": "Configure OAuth 1.0a credentials (access_token + access_token_secret) to enable write actions." })) } /// Execute retweet action — POST /2/users/:id/retweets /// /// **NOTE**: Twitter API v2 requires OAuth 1.0a user context for retweet. /// Bearer token (app-only auth) is not sufficient and will return 403. /// This action is currently unavailable until OAuth 1.0a signing is implemented. async fn execute_retweet(&self, tweet_id: &str) -> Result { let _ = tweet_id; tracing::warn!("[TwitterHand] retweet action requires OAuth 1.0a user context — not yet supported"); Ok(json!({ "success": false, "action": "retweet", "error": "OAuth 1.0a user context required. Retweet action is not yet supported with app-only Bearer token.", "suggestion": "Configure OAuth 1.0a credentials (access_token + access_token_secret) to enable write actions." })) } /// Execute delete tweet — DELETE /2/tweets/:id async fn execute_delete_tweet(&self, tweet_id: &str) -> Result { let creds = self.get_credentials().await .ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?; let client = reqwest::Client::new(); let url = format!("https://api.twitter.com/2/tweets/{}", tweet_id); let response = client.delete(&url) .header("Authorization", format!("Bearer {}", creds.bearer_token.as_deref().unwrap_or(""))) .header("User-Agent", "ZCLAW/1.0") .send() .await .map_err(|e| zclaw_types::ZclawError::HandError(format!("Delete tweet failed: {}", e)))?; let status = response.status(); let response_text = response.text().await.unwrap_or_default(); Ok(json!({ "success": status.is_success(), "tweet_id": tweet_id, "action": "deleted", "status_code": status.as_u16(), "message": if status.is_success() { "Tweet deleted" } else { &response_text } })) } /// Execute unretweet — DELETE /2/users/:id/retweets/:tweet_id async fn execute_unretweet(&self, tweet_id: &str) -> Result { let creds = self.get_credentials().await .ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?; let client = reqwest::Client::new(); let url = format!("https://api.twitter.com/2/users/me/retweets/{}", tweet_id); let response = client.delete(&url) .header("Authorization", format!("Bearer {}", creds.bearer_token.as_deref().unwrap_or(""))) .header("User-Agent", "ZCLAW/1.0") .send() .await .map_err(|e| zclaw_types::ZclawError::HandError(format!("Unretweet failed: {}", e)))?; let status = response.status(); let response_text = response.text().await.unwrap_or_default(); Ok(json!({ "success": status.is_success(), "tweet_id": tweet_id, "action": "unretweeted", "status_code": status.as_u16(), "message": if status.is_success() { "Tweet unretweeted" } else { &response_text } })) } /// Execute unlike — DELETE /2/users/:id/likes/:tweet_id async fn execute_unlike(&self, tweet_id: &str) -> Result { let creds = self.get_credentials().await .ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?; let client = reqwest::Client::new(); let url = format!("https://api.twitter.com/2/users/me/likes/{}", tweet_id); let response = client.delete(&url) .header("Authorization", format!("Bearer {}", creds.bearer_token.as_deref().unwrap_or(""))) .header("User-Agent", "ZCLAW/1.0") .send() .await .map_err(|e| zclaw_types::ZclawError::HandError(format!("Unlike failed: {}", e)))?; let status = response.status(); let response_text = response.text().await.unwrap_or_default(); Ok(json!({ "success": status.is_success(), "tweet_id": tweet_id, "action": "unliked", "status_code": status.as_u16(), "message": if status.is_success() { "Tweet unliked" } else { &response_text } })) } /// Execute followers fetch — GET /2/users/:id/followers async fn execute_followers(&self, user_id: &str, max_results: Option) -> Result { let creds = self.get_credentials().await .ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?; let client = reqwest::Client::new(); let url = format!("https://api.twitter.com/2/users/{}/followers", user_id); let max = max_results.unwrap_or(100).max(1).min(1000); let response = client.get(&url) .header("Authorization", format!("Bearer {}", creds.bearer_token.as_deref().unwrap_or(""))) .header("User-Agent", "ZCLAW/1.0") .query(&[ ("max_results", max.to_string()), ("user.fields", "created_at,description,public_metrics,verified,profile_image_url".to_string()), ]) .send() .await .map_err(|e| zclaw_types::ZclawError::HandError(format!("Followers fetch failed: {}", e)))?; let status = response.status(); let response_text = response.text().await .map_err(|e| zclaw_types::ZclawError::HandError(format!("Failed to read response: {}", e)))?; if !status.is_success() { return Ok(json!({ "success": false, "error": format!("Twitter API returned {}: {}", status, response_text), "status_code": status.as_u16() })); } let parsed: Value = serde_json::from_str(&response_text).unwrap_or(json!({"raw": response_text})); Ok(json!({ "success": true, "user_id": user_id, "followers": parsed["data"].as_array().cloned().unwrap_or_default(), "meta": parsed["meta"].clone(), "message": "Followers fetched" })) } /// Execute following fetch — GET /2/users/:id/following async fn execute_following(&self, user_id: &str, max_results: Option) -> Result { let creds = self.get_credentials().await .ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?; let client = reqwest::Client::new(); let url = format!("https://api.twitter.com/2/users/{}/following", user_id); let max = max_results.unwrap_or(100).max(1).min(1000); let response = client.get(&url) .header("Authorization", format!("Bearer {}", creds.bearer_token.as_deref().unwrap_or(""))) .header("User-Agent", "ZCLAW/1.0") .query(&[ ("max_results", max.to_string()), ("user.fields", "created_at,description,public_metrics,verified,profile_image_url".to_string()), ]) .send() .await .map_err(|e| zclaw_types::ZclawError::HandError(format!("Following fetch failed: {}", e)))?; let status = response.status(); let response_text = response.text().await .map_err(|e| zclaw_types::ZclawError::HandError(format!("Failed to read response: {}", e)))?; if !status.is_success() { return Ok(json!({ "success": false, "error": format!("Twitter API returned {}: {}", status, response_text), "status_code": status.as_u16() })); } let parsed: Value = serde_json::from_str(&response_text).unwrap_or(json!({"raw": response_text})); Ok(json!({ "success": true, "user_id": user_id, "following": parsed["data"].as_array().cloned().unwrap_or_default(), "meta": parsed["meta"].clone(), "message": "Following fetched" })) } /// Check credentials status async fn execute_check_credentials(&self) -> Result { match self.get_credentials().await { Some(creds) => { // Validate credentials have required fields let has_required = !creds.api_key.is_empty() && !creds.api_secret.is_empty() && !creds.access_token.is_empty() && !creds.access_token_secret.is_empty(); Ok(json!({ "configured": has_required, "has_api_key": !creds.api_key.is_empty(), "has_api_secret": !creds.api_secret.is_empty(), "has_access_token": !creds.access_token.is_empty(), "has_access_token_secret": !creds.access_token_secret.is_empty(), "has_bearer_token": creds.bearer_token.is_some(), "message": if has_required { "Twitter credentials configured" } else { "Twitter credentials incomplete" } })) } None => Ok(json!({ "configured": false, "message": "Twitter credentials not set", "setup_instructions": { "step1": "Create a Twitter Developer account at https://developer.twitter.com/", "step2": "Create a new project and app", "step3": "Generate API Key, API Secret, Access Token, and Access Token Secret", "step4": "Configure credentials using set_credentials()" } })) } } } impl Default for TwitterHand { fn default() -> Self { Self::new() } } #[async_trait] impl Hand for TwitterHand { fn config(&self) -> &HandConfig { &self.config } async fn execute(&self, _context: &HandContext, input: Value) -> Result { let action: TwitterAction = serde_json::from_value(input.clone()) .map_err(|e| zclaw_types::ZclawError::HandError(format!("Invalid action: {}", e)))?; let start = std::time::Instant::now(); let result = match action { TwitterAction::Tweet { config } => self.execute_tweet(&config).await?, TwitterAction::DeleteTweet { tweet_id } => self.execute_delete_tweet(&tweet_id).await?, TwitterAction::Retweet { tweet_id } => self.execute_retweet(&tweet_id).await?, TwitterAction::Unretweet { tweet_id } => self.execute_unretweet(&tweet_id).await?, TwitterAction::Like { tweet_id } => self.execute_like(&tweet_id).await?, TwitterAction::Unlike { tweet_id } => self.execute_unlike(&tweet_id).await?, TwitterAction::Search { config } => self.execute_search(&config).await?, TwitterAction::Timeline { config } => self.execute_timeline(&config).await?, TwitterAction::GetTweet { tweet_id } => self.execute_get_tweet(&tweet_id).await?, TwitterAction::GetUser { username } => self.execute_get_user(&username).await?, TwitterAction::Followers { user_id, max_results } => self.execute_followers(&user_id, max_results).await?, TwitterAction::Following { user_id, max_results } => self.execute_following(&user_id, max_results).await?, TwitterAction::CheckCredentials => self.execute_check_credentials().await?, }; let duration_ms = start.elapsed().as_millis() as u64; Ok(HandResult { success: result["success"].as_bool().unwrap_or(false), output: result, error: None, duration_ms: Some(duration_ms), status: "completed".to_string(), }) } fn needs_approval(&self) -> bool { true // Twitter actions should be approved } fn check_dependencies(&self) -> Result> { let mut missing = Vec::new(); // Check if credentials are configured (synchronously) // This is a simplified check; actual async check would require runtime missing.push("Twitter API credentials required".to_string()); Ok(missing) } fn status(&self) -> crate::HandStatus { // Will be Idle when credentials are set crate::HandStatus::Idle } } #[cfg(test)] mod tests { use super::*; use crate::Hand; use zclaw_types::id::AgentId; fn make_context() -> HandContext { HandContext { agent_id: AgentId::new(), working_dir: None, env: std::collections::HashMap::new(), timeout_secs: 30, callback_url: None, } } // === Config & Defaults === #[test] fn test_hand_config() { let hand = TwitterHand::new(); assert_eq!(hand.config().id, "twitter"); assert_eq!(hand.config().name, "Twitter 自动化"); assert!(hand.config().needs_approval); assert!(hand.config().enabled); assert!(hand.config().tags.contains(&"twitter".to_string())); assert!(hand.config().input_schema.is_some()); } #[test] fn test_default_impl() { let hand = TwitterHand::default(); assert_eq!(hand.config().id, "twitter"); } #[test] fn test_needs_approval() { let hand = TwitterHand::new(); assert!(hand.needs_approval()); } #[test] fn test_status() { let hand = TwitterHand::new(); assert_eq!(hand.status(), crate::HandStatus::Idle); } #[test] fn test_check_dependencies() { let hand = TwitterHand::new(); let deps = hand.check_dependencies().unwrap(); assert!(!deps.is_empty()); } // === Action Deserialization === #[test] fn test_tweet_action_deserialize() { let json = json!({ "action": "tweet", "config": { "text": "Hello world!" } }); let action: TwitterAction = serde_json::from_value(json).unwrap(); match action { TwitterAction::Tweet { config } => { assert_eq!(config.text, "Hello world!"); assert!(config.media_urls.is_empty()); assert!(config.reply_to.is_none()); assert!(config.quote_tweet.is_none()); assert!(config.poll.is_none()); } _ => panic!("Expected Tweet action"), } } #[test] fn test_tweet_action_with_reply() { let json = json!({ "action": "tweet", "config": { "text": "@user reply", "replyTo": "123456" } }); let action: TwitterAction = serde_json::from_value(json).unwrap(); match action { TwitterAction::Tweet { config } => { assert_eq!(config.reply_to.as_deref(), Some("123456")); } _ => panic!("Expected Tweet action"), } } #[test] fn test_tweet_action_with_poll() { let json = json!({ "action": "tweet", "config": { "text": "Vote!", "poll": { "options": ["A", "B", "C"], "durationMinutes": 60 } } }); let action: TwitterAction = serde_json::from_value(json).unwrap(); match action { TwitterAction::Tweet { config } => { let poll = config.poll.unwrap(); assert_eq!(poll.options, vec!["A", "B", "C"]); assert_eq!(poll.duration_minutes, 60); } _ => panic!("Expected Tweet action"), } } #[test] fn test_delete_tweet_action() { let json = json!({"action": "delete_tweet", "tweet_id": "789"}); let action: TwitterAction = serde_json::from_value(json).unwrap(); match action { TwitterAction::DeleteTweet { tweet_id } => assert_eq!(tweet_id, "789"), _ => panic!("Expected DeleteTweet"), } } #[test] fn test_like_unlike_actions() { let like: TwitterAction = serde_json::from_value(json!({"action": "like", "tweet_id": "111"})).unwrap(); match like { TwitterAction::Like { tweet_id } => assert_eq!(tweet_id, "111"), _ => panic!("Expected Like"), } let unlike: TwitterAction = serde_json::from_value(json!({"action": "unlike", "tweet_id": "111"})).unwrap(); match unlike { TwitterAction::Unlike { tweet_id } => assert_eq!(tweet_id, "111"), _ => panic!("Expected Unlike"), } } #[test] fn test_retweet_unretweet_actions() { let rt: TwitterAction = serde_json::from_value(json!({"action": "retweet", "tweet_id": "222"})).unwrap(); match rt { TwitterAction::Retweet { tweet_id } => assert_eq!(tweet_id, "222"), _ => panic!("Expected Retweet"), } let unrt: TwitterAction = serde_json::from_value(json!({"action": "unretweet", "tweet_id": "222"})).unwrap(); match unrt { TwitterAction::Unretweet { tweet_id } => assert_eq!(tweet_id, "222"), _ => panic!("Expected Unretweet"), } } #[test] fn test_search_action_defaults() { let json = json!({"action": "search", "config": {"query": "rust lang"}}); let action: TwitterAction = serde_json::from_value(json).unwrap(); match action { TwitterAction::Search { config } => { assert_eq!(config.query, "rust lang"); assert_eq!(config.max_results, 10); // default assert!(config.next_token.is_none()); } _ => panic!("Expected Search"), } } #[test] fn test_search_action_custom_max() { let json = json!({"action": "search", "config": {"query": "test", "maxResults": 50}}); let action: TwitterAction = serde_json::from_value(json).unwrap(); match action { TwitterAction::Search { config } => assert_eq!(config.max_results, 50), _ => panic!("Expected Search"), } } #[test] fn test_timeline_action_defaults() { let json = json!({"action": "timeline", "config": {}}); let action: TwitterAction = serde_json::from_value(json).unwrap(); match action { TwitterAction::Timeline { config } => { assert!(config.user_id.is_none()); assert_eq!(config.max_results, 10); // default assert!(!config.exclude_replies); assert!(config.include_retweets); } _ => panic!("Expected Timeline"), } } #[test] fn test_get_tweet_action() { let json = json!({"action": "get_tweet", "tweet_id": "999"}); let action: TwitterAction = serde_json::from_value(json).unwrap(); match action { TwitterAction::GetTweet { tweet_id } => assert_eq!(tweet_id, "999"), _ => panic!("Expected GetTweet"), } } #[test] fn test_get_user_action() { let json = json!({"action": "get_user", "username": "elonmusk"}); let action: TwitterAction = serde_json::from_value(json).unwrap(); match action { TwitterAction::GetUser { username } => assert_eq!(username, "elonmusk"), _ => panic!("Expected GetUser"), } } #[test] fn test_followers_action() { let json = json!({"action": "followers", "user_id": "u1", "max_results": 50}); let action: TwitterAction = serde_json::from_value(json).unwrap(); match action { TwitterAction::Followers { user_id, max_results } => { assert_eq!(user_id, "u1"); assert_eq!(max_results, Some(50)); } _ => panic!("Expected Followers"), } } #[test] fn test_following_action_no_max() { let json = json!({"action": "following", "user_id": "u2"}); let action: TwitterAction = serde_json::from_value(json).unwrap(); match action { TwitterAction::Following { user_id, max_results } => { assert_eq!(user_id, "u2"); assert!(max_results.is_none()); } _ => panic!("Expected Following"), } } #[test] fn test_check_credentials_action() { let json = json!({"action": "check_credentials"}); let action: TwitterAction = serde_json::from_value(json).unwrap(); match action { TwitterAction::CheckCredentials => {} _ => panic!("Expected CheckCredentials"), } } #[test] fn test_invalid_action() { let json = json!({"action": "invalid_action"}); let result = serde_json::from_value::(json); assert!(result.is_err()); } // === Serialization Roundtrip === #[test] fn test_tweet_action_roundtrip() { let json = json!({ "action": "tweet", "config": { "text": "Test tweet", "mediaUrls": ["https://example.com/img.jpg"], "replyTo": "123", "quoteTweet": "456" } }); let action: TwitterAction = serde_json::from_value(json).unwrap(); let serialized = serde_json::to_value(&action).unwrap(); // Verify core fields survive roundtrip (camelCase via serde rename) assert_eq!(serialized["action"], "tweet"); assert_eq!(serialized["config"]["text"], "Test tweet"); assert_eq!(serialized["config"]["mediaUrls"][0], "https://example.com/img.jpg"); assert_eq!(serialized["config"]["replyTo"], "123"); assert_eq!(serialized["config"]["quoteTweet"], "456"); } #[test] fn test_search_action_roundtrip() { let json = json!({ "action": "search", "config": { "query": "hello world", "maxResults": 25 } }); let action: TwitterAction = serde_json::from_value(json).unwrap(); let serialized = serde_json::to_value(&action).unwrap(); assert_eq!(serialized["action"], "search"); assert_eq!(serialized["config"]["query"], "hello world"); assert_eq!(serialized["config"]["maxResults"], 25); } // === Credentials === #[tokio::test] async fn test_set_and_get_credentials() { let hand = TwitterHand::new(); // Initially no credentials assert!(hand.get_credentials().await.is_none()); // Set credentials hand.set_credentials(TwitterCredentials { api_key: "key".into(), api_secret: "secret".into(), access_token: "token".into(), access_token_secret: "token_secret".into(), bearer_token: Some("bearer".into()), }).await; let creds = hand.get_credentials().await.unwrap(); assert_eq!(creds.api_key, "key"); assert_eq!(creds.bearer_token.as_deref(), Some("bearer")); } #[tokio::test] async fn test_check_credentials_without_config() { let hand = TwitterHand::new(); let ctx = make_context(); let result = hand.execute(&ctx, json!({"action": "check_credentials"})).await.unwrap(); // No "success" field in output → HandResult.success defaults to false assert!(!result.success); assert_eq!(result.output["configured"], false); } #[tokio::test] async fn test_check_credentials_with_config() { let hand = TwitterHand::new(); hand.set_credentials(TwitterCredentials { api_key: "key".into(), api_secret: "secret".into(), access_token: "token".into(), access_token_secret: "token_secret".into(), bearer_token: Some("bearer".into()), }).await; let ctx = make_context(); let result = hand.execute(&ctx, json!({"action": "check_credentials"})).await.unwrap(); // execute_check_credentials returns {"configured": true, ...} without "success" field // HandResult.success = result["success"].as_bool().unwrap_or(false) = false // But the actual data is in output assert_eq!(result.output["configured"], true); assert_eq!(result.output["has_bearer_token"], true); } // === Tweet Data Types === #[test] fn test_tweet_deserialize() { let json = json!({ "id": "t123", "text": "Hello!", "authorId": "a456", "authorName": "Test User", "authorUsername": "testuser", "createdAt": "2026-01-01T00:00:00Z", "publicMetrics": { "retweetCount": 5, "replyCount": 2, "likeCount": 10, "quoteCount": 1, "impressionCount": 1000 } }); let tweet: Tweet = serde_json::from_value(json).unwrap(); assert_eq!(tweet.id, "t123"); assert_eq!(tweet.public_metrics.like_count, 10); assert!(tweet.media.is_empty()); } #[test] fn test_twitter_user_deserialize() { let json = json!({ "id": "u1", "name": "Alice", "username": "alice", "verified": true, "publicMetrics": { "followersCount": 100, "followingCount": 50, "tweetCount": 1000, "listedCount": 5 } }); let user: TwitterUser = serde_json::from_value(json).unwrap(); assert_eq!(user.username, "alice"); assert!(user.verified); assert_eq!(user.public_metrics.followers_count, 100); } #[test] fn test_media_info_deserialize() { let json = json!({ "mediaKey": "mk1", "mediaType": "photo", "url": "https://pbs.example.com/photo.jpg", "width": 1200, "height": 800 }); let media: MediaInfo = serde_json::from_value(json).unwrap(); assert_eq!(media.media_type, "photo"); assert_eq!(media.width, 1200); } }