Files
zclaw_openfang/crates/zclaw-hands/src/hands/twitter.rs
iven 3f2acb49fb fix: pre-release audit fixes — Twitter OAuth, DataMasking perf, Prompt versioning
- Twitter like/retweet: return explicit unavailable error instead of
  sending doomed Bearer token requests (would 403 on Twitter API v2)
- DataMasking: pre-compile regex patterns with LazyLock (was compiling
  6 patterns on every mask() call)
- Prompt version: fix get_version handler ignoring version path param,
  add service::get_version_by_number for correct per-version retrieval
2026-04-09 16:43:24 +08:00

1214 lines
44 KiB
Rust

//! 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<String>,
}
/// 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<String>,
/// Reply to tweet ID
#[serde(default)]
pub reply_to: Option<String>,
/// Quote tweet ID
#[serde(default)]
pub quote_tweet: Option<String>,
/// Poll configuration
#[serde(default)]
pub poll: Option<PollConfig>,
}
/// Poll configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PollConfig {
pub options: Vec<String>,
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<String>,
}
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<String>,
/// 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<MediaInfo>,
}
/// 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<u64>,
}
/// 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<String>,
pub profile_image_url: Option<String>,
pub location: Option<String>,
pub url: Option<String>,
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<u32> },
#[serde(rename = "following")]
Following { user_id: String, max_results: Option<u32> },
#[serde(rename = "check_credentials")]
CheckCredentials,
}
/// Twitter Hand implementation
pub struct TwitterHand {
config: HandConfig,
credentials: Arc<RwLock<Option<TwitterCredentials>>>,
}
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<TwitterCredentials> {
let c = self.credentials.read().await;
c.clone()
}
/// Execute tweet action — POST /2/tweets
async fn execute_tweet(&self, config: &TweetConfig) -> Result<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<u32>) -> Result<Value> {
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<u32>) -> Result<Value> {
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<Value> {
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<HandResult> {
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<Vec<String>> {
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::<TwitterAction>(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);
}
}