use std::fmt::Write as _;
use std::future::Future;
use std::pin::Pin;
use std::time::SystemTime;
use base64::Engine;
use hmac::{Hmac, Mac};
use serde_json::json;
use sha1::Sha1;
use crate::error::Error;
use crate::llm::types::ToolDefinition;
use crate::tool::{Tool, ToolOutput};
const X_API_URL: &str = "https://api.twitter.com/2/tweets";
const MAX_TWEET_LENGTH: usize = 280;
type HmacSha1 = Hmac<Sha1>;
#[derive(Clone)]
pub struct TwitterCredentials {
pub consumer_key: String,
pub consumer_secret: String,
pub access_token: String,
pub access_token_secret: String,
}
impl std::fmt::Debug for TwitterCredentials {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TwitterCredentials")
.field("consumer_key", &"[REDACTED]")
.field("consumer_secret", &"[REDACTED]")
.field("access_token", &"[REDACTED]")
.field("access_token_secret", &"[REDACTED]")
.finish()
}
}
pub struct TwitterPostTool {
credentials: TwitterCredentials,
client: reqwest::Client,
tweet_url: String,
media_upload_url: String,
media_meta_url: String,
}
impl TwitterPostTool {
pub fn new(credentials: TwitterCredentials) -> Self {
Self::try_new(credentials).expect("failed to build reqwest client")
}
pub fn try_new(credentials: TwitterCredentials) -> Result<Self, crate::error::Error> {
let client = crate::http::vendor_client_builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| {
crate::error::Error::Agent(format!("failed to build reqwest client: {e}"))
})?;
Ok(Self {
credentials,
client,
tweet_url: X_API_URL.to_string(),
media_upload_url: "https://upload.twitter.com/1.1/media/upload.json".to_string(),
media_meta_url: "https://upload.twitter.com/1.1/media/metadata/create.json".to_string(),
})
}
}
#[cfg(test)]
impl TwitterPostTool {
pub(crate) fn new_with_base_urls(
credentials: TwitterCredentials,
tweet_url: String,
media_upload_url: String,
media_meta_url: String,
) -> Self {
let client = crate::http::vendor_client_builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.expect("test client builds");
Self {
credentials,
client,
tweet_url,
media_upload_url,
media_meta_url,
}
}
}
fn percent_encode(s: &str) -> String {
let mut encoded = String::with_capacity(s.len() * 2);
for byte in s.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
encoded.push(byte as char);
}
_ => {
let _ = write!(encoded, "%{byte:02X}");
}
}
}
encoded
}
fn build_oauth_header(
url: &str,
consumer_key: &str,
consumer_secret: &str,
access_token: &str,
access_token_secret: &str,
nonce: &str,
timestamp: u64,
) -> Result<String, Error> {
let oauth_params = [
("oauth_consumer_key", consumer_key),
("oauth_nonce", nonce),
("oauth_signature_method", "HMAC-SHA1"),
("oauth_timestamp", ×tamp.to_string()),
("oauth_token", access_token),
("oauth_version", "1.0"),
];
let param_string: String = oauth_params
.iter()
.map(|(k, v)| format!("{}={}", percent_encode(k), percent_encode(v)))
.collect::<Vec<_>>()
.join("&");
let base_string = format!(
"POST&{}&{}",
percent_encode(url),
percent_encode(¶m_string),
);
let signing_key = format!(
"{}&{}",
percent_encode(consumer_secret),
percent_encode(access_token_secret),
);
let mut mac = HmacSha1::new_from_slice(signing_key.as_bytes())
.map_err(|e| Error::Agent(format!("HMAC key error: {e}")))?;
mac.update(base_string.as_bytes());
let signature = base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes());
Ok(format!(
"OAuth oauth_consumer_key=\"{}\", \
oauth_nonce=\"{}\", \
oauth_signature=\"{}\", \
oauth_signature_method=\"HMAC-SHA1\", \
oauth_timestamp=\"{}\", \
oauth_token=\"{}\", \
oauth_version=\"1.0\"",
percent_encode(consumer_key),
percent_encode(nonce),
percent_encode(&signature),
timestamp,
percent_encode(access_token),
))
}
impl TwitterPostTool {
async fn upload_media(
&self,
media_url: &str,
media_alt_text: Option<&str>,
) -> Result<String, Error> {
let bytes_resp = self
.client
.get(media_url)
.send()
.await
.map_err(|e| Error::Agent(format!("media fetch failed: {e}")))?;
let status = bytes_resp.status();
if !status.is_success() {
return Err(Error::Agent(format!(
"media fetch returned status {}",
status.as_u16()
)));
}
const MEDIA_LIMIT: usize = 5 * 1024 * 1024;
let (body, truncated) = crate::http::read_body_capped(bytes_resp, MEDIA_LIMIT + 1)
.await
.map_err(|e| Error::Agent(format!("media body read failed: {e}")))?;
if truncated || body.len() > MEDIA_LIMIT {
return Err(Error::Agent(format!(
"media exceeds 5 MB limit (read at least {} bytes)",
body.len()
)));
}
let timestamp = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_err(|e| Error::Agent(format!("system time error: {e}")))?
.as_secs();
let nonce = format!("{}{}", timestamp, ×tamp.to_string()[..6]);
let auth_header = build_oauth_header(
&self.media_upload_url,
&self.credentials.consumer_key,
&self.credentials.consumer_secret,
&self.credentials.access_token,
&self.credentials.access_token_secret,
&nonce,
timestamp,
)?;
let form = reqwest::multipart::Form::new().part(
"media",
reqwest::multipart::Part::bytes(body.to_vec()).file_name("media"),
);
let response = self
.client
.post(&self.media_upload_url)
.header("Authorization", auth_header)
.multipart(form)
.send()
.await
.map_err(|e| Error::Agent(format!("media upload failed: {e}")))?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(Error::Agent(format!(
"media upload returned status {}: {body}",
status.as_u16()
)));
}
let parsed: serde_json::Value = response
.json()
.await
.map_err(|e| Error::Agent(format!("media upload parse failed: {e}")))?;
let media_id_string = parsed
.get("media_id_string")
.and_then(|v| v.as_str())
.ok_or_else(|| Error::Agent("media upload returned no media_id_string".into()))?
.to_string();
if let Some(alt) = media_alt_text {
let meta_timestamp = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_err(|e| Error::Agent(format!("system time error: {e}")))?
.as_secs();
let meta_nonce = format!("{}{}", meta_timestamp, &meta_timestamp.to_string()[..6]);
let meta_auth = build_oauth_header(
&self.media_meta_url,
&self.credentials.consumer_key,
&self.credentials.consumer_secret,
&self.credentials.access_token,
&self.credentials.access_token_secret,
&meta_nonce,
meta_timestamp,
)?;
let body = json!({
"media_id": media_id_string,
"alt_text": {"text": alt}
});
let _ = self
.client
.post(&self.media_meta_url)
.header("Authorization", meta_auth)
.json(&body)
.send()
.await
.map_err(|e| Error::Agent(format!("alt-text attach failed: {e}")))?;
}
Ok(media_id_string)
}
}
impl Tool for TwitterPostTool {
fn definition(&self) -> ToolDefinition {
ToolDefinition {
name: "twitter_post".into(),
description: "Post a tweet to X/Twitter. Maximum 280 characters. Optionally attaches one image via media_url with an accessibility description via media_alt_text.".into(),
input_schema: json!({
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "The tweet text to post (max 280 characters)"
},
"media_url": {
"type": "string",
"description": "Optional. Public URL of one image to attach (≤5 MB, JPEG/PNG/WebP/GIF). HTTPS recommended."
},
"media_alt_text": {
"type": "string",
"description": "Optional. Accessibility description for the image (≤1000 chars). Ignored if media_url is absent.",
"maxLength": 1000
}
},
"required": ["text"]
}),
}
}
fn execute(
&self,
_ctx: &crate::ExecutionContext,
input: serde_json::Value,
) -> Pin<Box<dyn Future<Output = Result<ToolOutput, Error>> + Send + '_>> {
Box::pin(async move {
let text = input
.get("text")
.and_then(|v| v.as_str())
.ok_or_else(|| Error::Agent("text is required".into()))?;
if text.is_empty() {
return Ok(ToolOutput::error("text must not be empty"));
}
let char_count = text.chars().count();
if char_count > MAX_TWEET_LENGTH {
return Ok(ToolOutput::error(format!(
"Tweet exceeds {MAX_TWEET_LENGTH} characters (got {char_count}). \
Please shorten your tweet."
)));
}
let media_url = input.get("media_url").and_then(|v| v.as_str());
let media_alt_text = input.get("media_alt_text").and_then(|v| v.as_str());
let media_id_string: Option<String> = if let Some(url) = media_url {
match self.upload_media(url, media_alt_text).await {
Ok(id) => Some(id),
Err(e) => return Ok(ToolOutput::error(format!("media upload failed: {e}"))),
}
} else {
None
};
let timestamp = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_err(|e| Error::Agent(format!("system time error: {e}")))?
.as_secs();
let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
let auth_header = build_oauth_header(
&self.tweet_url,
&self.credentials.consumer_key,
&self.credentials.consumer_secret,
&self.credentials.access_token,
&self.credentials.access_token_secret,
&nonce,
timestamp,
)?;
let body = if let Some(ref id) = media_id_string {
json!({
"text": text,
"media": {"media_ids": [id]}
})
} else {
json!({"text": text})
};
let response = self
.client
.post(&self.tweet_url)
.header("Authorization", &auth_header)
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.map_err(|e| Error::Agent(format!("X API request failed: {e}")))?;
let status = response.status();
let (body_bytes, _truncated) = crate::http::read_body_capped(response, 256 * 1024)
.await
.map_err(|e| Error::Agent(format!("Failed to read X API response: {e}")))?;
let response_body: serde_json::Value = serde_json::from_slice(&body_bytes)
.map_err(|e| Error::Agent(format!("Failed to parse X API response: {e}")))?;
if !status.is_success() {
let detail = response_body
.get("detail")
.and_then(|v| v.as_str())
.or_else(|| response_body.get("title").and_then(|v| v.as_str()))
.unwrap_or("Unknown error");
return Ok(ToolOutput::error(format!(
"X API error (HTTP {}): {detail}",
status.as_u16()
)));
}
let tweet_id = response_body
.get("data")
.and_then(|d| d.get("id"))
.and_then(|v| v.as_str())
.unwrap_or("unknown");
Ok(ToolOutput::success(format!(
"Tweet posted successfully!\n\
Tweet ID: {tweet_id}\n\
URL: https://x.com/i/status/{tweet_id}\n\
Text: {text}"
)))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_credentials() -> TwitterCredentials {
TwitterCredentials {
consumer_key: "test_consumer_key".into(),
consumer_secret: "test_consumer_secret".into(),
access_token: "test_access_token".into(),
access_token_secret: "test_access_token_secret".into(),
}
}
#[test]
fn definition_has_correct_name() {
let tool = TwitterPostTool::new(test_credentials());
assert_eq!(tool.definition().name, "twitter_post");
}
#[test]
fn definition_requires_text() {
let tool = TwitterPostTool::new(test_credentials());
let schema = &tool.definition().input_schema;
let required = schema["required"].as_array().unwrap();
assert_eq!(required.len(), 1);
assert_eq!(required[0], "text");
}
#[test]
fn percent_encode_unreserved() {
assert_eq!(percent_encode("abc123"), "abc123");
assert_eq!(
percent_encode("hello-world_test.v2~"),
"hello-world_test.v2~"
);
}
#[test]
fn percent_encode_reserved() {
assert_eq!(percent_encode("hello world"), "hello%20world");
assert_eq!(percent_encode("a&b=c"), "a%26b%3Dc");
assert_eq!(percent_encode("100%"), "100%25");
}
#[test]
fn percent_encode_special_chars() {
assert_eq!(percent_encode("/"), "%2F");
assert_eq!(percent_encode(":"), "%3A");
assert_eq!(percent_encode("@"), "%40");
}
#[test]
fn build_oauth_header_produces_valid_format() {
let header = build_oauth_header(
"https://api.twitter.com/2/tweets",
"consumer_key",
"consumer_secret",
"access_token",
"access_token_secret",
"testnonce123",
1234567890,
)
.unwrap();
assert!(header.starts_with("OAuth "));
assert!(header.contains("oauth_consumer_key=\"consumer_key\""));
assert!(header.contains("oauth_nonce=\"testnonce123\""));
assert!(header.contains("oauth_signature_method=\"HMAC-SHA1\""));
assert!(header.contains("oauth_timestamp=\"1234567890\""));
assert!(header.contains("oauth_token=\"access_token\""));
assert!(header.contains("oauth_version=\"1.0\""));
assert!(header.contains("oauth_signature=\""));
}
#[test]
fn build_oauth_header_signature_is_deterministic() {
let h1 = build_oauth_header(X_API_URL, "ck", "cs", "at", "ats", "nonce", 1000).unwrap();
let h2 = build_oauth_header(X_API_URL, "ck", "cs", "at", "ats", "nonce", 1000).unwrap();
assert_eq!(h1, h2);
}
#[test]
fn build_oauth_header_different_nonce_produces_different_signature() {
let h1 = build_oauth_header(X_API_URL, "ck", "cs", "at", "ats", "nonce1", 1000).unwrap();
let h2 = build_oauth_header(X_API_URL, "ck", "cs", "at", "ats", "nonce2", 1000).unwrap();
assert_ne!(h1, h2);
}
#[tokio::test]
async fn rejects_empty_text() {
let tool = TwitterPostTool::new(test_credentials());
let result = tool
.execute(&crate::ExecutionContext::default(), json!({"text": ""}))
.await
.unwrap();
assert!(result.is_error);
assert!(result.content.contains("must not be empty"));
}
#[tokio::test]
async fn rejects_text_too_long() {
let tool = TwitterPostTool::new(test_credentials());
let long = "a".repeat(281);
let result = tool
.execute(&crate::ExecutionContext::default(), json!({"text": long}))
.await
.unwrap();
assert!(result.is_error);
assert!(result.content.contains("exceeds 280 characters"));
}
#[tokio::test]
async fn rejects_missing_text() {
let tool = TwitterPostTool::new(test_credentials());
let result = tool
.execute(&crate::ExecutionContext::default(), json!({}))
.await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("text is required"), "got: {err}");
}
#[test]
fn credentials_debug_redacts_secrets() {
let creds = test_credentials();
let debug = format!("{creds:?}");
assert!(debug.contains("[REDACTED]"));
assert!(!debug.contains("test_consumer_key"));
assert!(!debug.contains("test_consumer_secret"));
}
#[tokio::test]
async fn accepts_280_chars() {
let tool = TwitterPostTool::new(test_credentials());
let text = "a".repeat(280);
let result = tool
.execute(&crate::ExecutionContext::default(), json!({"text": text}))
.await;
match result {
Ok(output) => {
if output.is_error {
assert!(
!output.content.contains("exceeds"),
"280 chars should not be rejected: {}",
output.content
);
}
}
Err(_) => {
}
}
}
#[tokio::test]
async fn post_with_media_url_and_alt_text() {
use wiremock::matchers::{method, path as wm_path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(wm_path("/test-image.png"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(vec![0u8; 1024])
.insert_header("Content-Type", "image/png"),
)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(wm_path("/1.1/media/upload.json"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"media_id_string": "999888777",
"media_id": 999888777u64
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(wm_path("/1.1/media/metadata/create.json"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(wm_path("/2/tweets"))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"data": {"id": "5555"}
})))
.mount(&server)
.await;
let media_url = format!("{}/test-image.png", server.uri());
let tool = TwitterPostTool::new_with_base_urls(
test_credentials(),
format!("{}/2/tweets", server.uri()),
format!("{}/1.1/media/upload.json", server.uri()),
format!("{}/1.1/media/metadata/create.json", server.uri()),
);
let ctx = crate::ExecutionContext::default();
let input = json!({
"text": "look at this",
"media_url": media_url,
"media_alt_text": "a square of zeros"
});
let result = tool.execute(&ctx, input).await.expect("ok");
assert!(!result.is_error);
assert!(result.content.contains("5555"));
}
#[tokio::test]
async fn post_text_only_still_works_without_media_fields() {
use wiremock::matchers::{method, path as wm_path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(wm_path("/2/tweets"))
.respond_with(
ResponseTemplate::new(201)
.set_body_json(serde_json::json!({"data": {"id": "111"}})),
)
.mount(&server)
.await;
let tool = TwitterPostTool::new_with_base_urls(
test_credentials(),
format!("{}/2/tweets", server.uri()),
format!("{}/1.1/media/upload.json", server.uri()),
format!("{}/1.1/media/metadata/create.json", server.uri()),
);
let ctx = crate::ExecutionContext::default();
let input = json!({"text": "no media"});
let result = tool.execute(&ctx, input).await.expect("ok");
assert!(!result.is_error);
}
#[tokio::test]
async fn post_rejects_oversized_media() {
use wiremock::matchers::{method, path as wm_path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(wm_path("/big.png"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(vec![0u8; 6 * 1024 * 1024])
.insert_header("Content-Type", "image/png"),
)
.mount(&server)
.await;
let tool = TwitterPostTool::new_with_base_urls(
test_credentials(),
format!("{}/2/tweets", server.uri()),
format!("{}/1.1/media/upload.json", server.uri()),
format!("{}/1.1/media/metadata/create.json", server.uri()),
);
let ctx = crate::ExecutionContext::default();
let media_url = format!("{}/big.png", server.uri());
let input = json!({
"text": "won't fit",
"media_url": media_url
});
let result = tool
.execute(&ctx, input)
.await
.expect("Tool::execute returns Ok");
assert!(result.is_error);
assert!(result.content.contains("5 MB") || result.content.contains("exceeds"));
}
#[tokio::test]
async fn post_handles_404_on_media_url() {
use wiremock::matchers::{method, path as wm_path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(wm_path("/missing.png"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let tool = TwitterPostTool::new_with_base_urls(
test_credentials(),
format!("{}/2/tweets", server.uri()),
format!("{}/1.1/media/upload.json", server.uri()),
format!("{}/1.1/media/metadata/create.json", server.uri()),
);
let ctx = crate::ExecutionContext::default();
let media_url = format!("{}/missing.png", server.uri());
let input = json!({
"text": "broken link",
"media_url": media_url
});
let result = tool
.execute(&ctx, input)
.await
.expect("Tool::execute returns Ok");
assert!(result.is_error);
assert!(result.content.contains("404") || result.content.contains("media fetch"));
}
}