molten-herald 0.1.0

Automated viral tweet generation and scheduling for developer releases 📢
Documentation
//! Twitter/X API integration

use crate::config::TwitterConfig;
use crate::error::{HeraldError, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tracing::{debug, info, warn};

/// Twitter API client
pub struct TwitterClient {
    config: TwitterConfig,
    client: reqwest::Client,
}

impl TwitterClient {
    /// Create a new Twitter client
    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(),
        })
    }

    /// Post a tweet
    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(),
        })
    }

    /// Post a thread (multiple tweets)
    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);

            // Small delay between thread tweets
            if i < tweets.len() - 1 {
                tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
            }
        }

        info!("Posted thread with {} tweets", posted.len());
        Ok(posted)
    }

    /// Delete a tweet
    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(())
    }

    /// Get rate limit status
    pub async fn rate_limit_status(&self) -> Result<RateLimitStatus> {
        // Twitter v2 doesn't have a dedicated rate limit endpoint
        // We infer from response headers
        Ok(RateLimitStatus {
            limit: 300,
            remaining: 300,
            reset: Utc::now() + chrono::Duration::minutes(15),
        })
    }

    /// Generate OAuth 1.0a header
    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", &timestamp),
            ("oauth_token", &self.config.access_token),
            ("oauth_version", "1.0"),
        ];

        // Combine with request params
        let mut all_params: Vec<(&str, &str)> = oauth_params.clone();
        all_params.extend_from_slice(params);
        all_params.sort_by_key(|p| p.0);

        // Create parameter string
        let param_string: String = all_params
            .iter()
            .map(|(k, v)| format!("{}={}", percent_encode(k), percent_encode(v)))
            .collect::<Vec<_>>()
            .join("&");

        // Create signature base string
        let base_string = format!(
            "{}&{}&{}",
            method.to_uppercase(),
            percent_encode(url),
            percent_encode(&param_string)
        );

        // Create signing key
        let signing_key = format!(
            "{}&{}",
            percent_encode(&self.config.api_secret),
            percent_encode(&self.config.access_token_secret)
        );

        // Generate signature
        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()
        );

        // Build OAuth header
        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))
    }
}

/// Percent-encode a string for OAuth
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
}

/// A successfully posted tweet
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PostedTweet {
    /// Tweet ID
    pub id: String,

    /// Tweet text
    pub text: String,

    /// When it was posted
    pub posted_at: DateTime<Utc>,

    /// URL to the tweet
    pub url: String,
}

/// Rate limit status
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RateLimitStatus {
    /// Total limit
    pub limit: u32,

    /// Remaining requests
    pub remaining: u32,

    /// Reset time
    pub reset: DateTime<Utc>,
}

/// Twitter API response for posting
#[derive(Debug, Deserialize)]
struct TwitterPostResponse {
    data: TwitterPostData,
}

#[derive(Debug, Deserialize)]
struct TwitterPostData {
    id: String,
}

/// Mock client for testing
#[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!");
    }
}