- 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
1214 lines
44 KiB
Rust
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);
|
|
}
|
|
}
|