use crate::config::TwitterConfig;
use crate::error::{HeraldError, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tracing::{debug, info, warn};
pub struct TwitterClient {
config: TwitterConfig,
client: reqwest::Client,
}
impl TwitterClient {
pub fn new(config: TwitterConfig) -> Result<Self> {
if !config.is_configured() {
return Err(HeraldError::Auth(
"Twitter API credentials not configured. Set TWITTER_API_KEY, TWITTER_API_SECRET, TWITTER_ACCESS_TOKEN, TWITTER_ACCESS_TOKEN_SECRET".to_string()
));
}
Ok(Self {
config,
client: reqwest::Client::new(),
})
}
pub async fn post_tweet(&self, text: &str) -> Result<PostedTweet> {
let url = "https://api.twitter.com/2/tweets";
let body = serde_json::json!({
"text": text
});
let auth_header = self.generate_oauth_header("POST", url, &[])?;
debug!("Posting tweet: {} chars", text.len());
let response = self.client
.post(url)
.header("Authorization", auth_header)
.header("Content-Type", "application/json")
.json(&body)
.send()
.await?;
let status = response.status();
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
let reset = response
.headers()
.get("x-rate-limit-reset")
.and_then(|v| v.to_str().ok())
.unwrap_or("unknown");
return Err(HeraldError::RateLimit(reset.to_string()));
}
if !status.is_success() {
let error_body = response.text().await?;
return Err(HeraldError::Twitter(format!(
"API error ({}): {}",
status,
error_body
)));
}
let data: TwitterPostResponse = response.json().await?;
info!("Tweet posted successfully: {}", data.data.id);
let tweet_id = data.data.id;
Ok(PostedTweet {
url: format!("https://twitter.com/i/web/status/{}", tweet_id),
id: tweet_id,
text: text.to_string(),
posted_at: Utc::now(),
})
}
pub async fn post_thread(&self, tweets: &[String]) -> Result<Vec<PostedTweet>> {
let mut posted = Vec::new();
let mut reply_to: Option<String> = None;
for (i, text) in tweets.iter().enumerate() {
debug!("Posting thread tweet {}/{}", i + 1, tweets.len());
let url = "https://api.twitter.com/2/tweets";
let body = if let Some(ref reply_id) = reply_to {
serde_json::json!({
"text": text,
"reply": {
"in_reply_to_tweet_id": reply_id
}
})
} else {
serde_json::json!({
"text": text
})
};
let auth_header = self.generate_oauth_header("POST", url, &[])?;
let response = self.client
.post(url)
.header("Authorization", auth_header)
.header("Content-Type", "application/json")
.json(&body)
.send()
.await?;
if !response.status().is_success() {
let error = response.text().await?;
warn!("Failed to post thread tweet {}: {}", i + 1, error);
break;
}
let data: TwitterPostResponse = response.json().await?;
let tweet_id = data.data.id;
posted.push(PostedTweet {
url: format!("https://twitter.com/i/web/status/{}", tweet_id),
id: tweet_id.clone(),
text: text.clone(),
posted_at: Utc::now(),
});
reply_to = Some(tweet_id);
if i < tweets.len() - 1 {
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
}
}
info!("Posted thread with {} tweets", posted.len());
Ok(posted)
}
pub async fn delete_tweet(&self, tweet_id: &str) -> Result<()> {
let url = format!("https://api.twitter.com/2/tweets/{}", tweet_id);
let auth_header = self.generate_oauth_header("DELETE", &url, &[])?;
let response = self.client
.delete(&url)
.header("Authorization", auth_header)
.send()
.await?;
if !response.status().is_success() {
let error = response.text().await?;
return Err(HeraldError::Twitter(format!("Delete failed: {}", error)));
}
info!("Deleted tweet: {}", tweet_id);
Ok(())
}
pub async fn rate_limit_status(&self) -> Result<RateLimitStatus> {
Ok(RateLimitStatus {
limit: 300,
remaining: 300,
reset: Utc::now() + chrono::Duration::minutes(15),
})
}
fn generate_oauth_header(&self, method: &str, url: &str, params: &[(&str, &str)]) -> Result<String> {
use hmac::{Hmac, Mac};
use sha1::Sha1;
let timestamp = Utc::now().timestamp().to_string();
let nonce: String = (0..32)
.map(|_| {
let idx = rand::random::<usize>() % 62;
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
.chars()
.nth(idx)
.unwrap()
})
.collect();
let mut oauth_params: Vec<(&str, &str)> = vec![
("oauth_consumer_key", &self.config.api_key),
("oauth_nonce", &nonce),
("oauth_signature_method", "HMAC-SHA1"),
("oauth_timestamp", ×tamp),
("oauth_token", &self.config.access_token),
("oauth_version", "1.0"),
];
let mut all_params: Vec<(&str, &str)> = oauth_params.clone();
all_params.extend_from_slice(params);
all_params.sort_by_key(|p| p.0);
let param_string: String = all_params
.iter()
.map(|(k, v)| format!("{}={}", percent_encode(k), percent_encode(v)))
.collect::<Vec<_>>()
.join("&");
let base_string = format!(
"{}&{}&{}",
method.to_uppercase(),
percent_encode(url),
percent_encode(¶m_string)
);
let signing_key = format!(
"{}&{}",
percent_encode(&self.config.api_secret),
percent_encode(&self.config.access_token_secret)
);
type HmacSha1 = Hmac<Sha1>;
let mut mac = HmacSha1::new_from_slice(signing_key.as_bytes())
.map_err(|e| HeraldError::Auth(format!("HMAC error: {}", e)))?;
mac.update(base_string.as_bytes());
let signature = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
mac.finalize().into_bytes()
);
oauth_params.push(("oauth_signature", &signature));
let oauth_header: String = oauth_params
.iter()
.map(|(k, v)| format!("{}=\"{}\"", k, percent_encode(v)))
.collect::<Vec<_>>()
.join(", ");
Ok(format!("OAuth {}", oauth_header))
}
}
fn percent_encode(s: &str) -> String {
let mut result = String::new();
for byte in s.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
result.push(byte as char);
}
_ => {
result.push_str(&format!("%{:02X}", byte));
}
}
}
result
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PostedTweet {
pub id: String,
pub text: String,
pub posted_at: DateTime<Utc>,
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RateLimitStatus {
pub limit: u32,
pub remaining: u32,
pub reset: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
struct TwitterPostResponse {
data: TwitterPostData,
}
#[derive(Debug, Deserialize)]
struct TwitterPostData {
id: String,
}
#[cfg(test)]
pub struct MockTwitterClient {
posted: std::sync::Mutex<Vec<String>>,
}
#[cfg(test)]
impl MockTwitterClient {
pub fn new() -> Self {
Self {
posted: std::sync::Mutex::new(Vec::new()),
}
}
pub fn post_tweet(&self, text: &str) -> Result<PostedTweet> {
self.posted.lock().unwrap().push(text.to_string());
Ok(PostedTweet {
id: uuid::Uuid::new_v4().to_string(),
text: text.to_string(),
posted_at: Utc::now(),
url: "https://twitter.com/test/status/123".to_string(),
})
}
pub fn get_posted(&self) -> Vec<String> {
self.posted.lock().unwrap().clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_percent_encode() {
assert_eq!(percent_encode("hello"), "hello");
assert_eq!(percent_encode("hello world"), "hello%20world");
assert_eq!(percent_encode("a=b&c=d"), "a%3Db%26c%3Dd");
}
#[test]
fn test_posted_tweet() {
let tweet = PostedTweet {
id: "123".to_string(),
text: "Hello!".to_string(),
posted_at: Utc::now(),
url: "https://twitter.com/test/status/123".to_string(),
};
let json = serde_json::to_string(&tweet).unwrap();
let parsed: PostedTweet = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.id, "123");
}
#[test]
fn test_mock_client() {
let client = MockTwitterClient::new();
client.post_tweet("Hello world!").unwrap();
client.post_tweet("Second tweet").unwrap();
let posted = client.get_posted();
assert_eq!(posted.len(), 2);
assert_eq!(posted[0], "Hello world!");
}
}