1use std::fmt::Write as _;
2use std::future::Future;
3use std::pin::Pin;
4use std::time::SystemTime;
5
6use base64::Engine;
7use hmac::{Hmac, Mac};
8use serde_json::json;
9use sha1::Sha1;
10
11use crate::error::Error;
12use crate::llm::types::ToolDefinition;
13use crate::tool::{Tool, ToolOutput};
14
15const X_API_URL: &str = "https://api.twitter.com/2/tweets";
16const MAX_TWEET_LENGTH: usize = 280;
17
18type HmacSha1 = Hmac<Sha1>;
19
20#[derive(Clone)]
22pub struct TwitterCredentials {
23 pub consumer_key: String,
25 pub consumer_secret: String,
27 pub access_token: String,
29 pub access_token_secret: String,
31}
32
33impl std::fmt::Debug for TwitterCredentials {
34 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35 f.debug_struct("TwitterCredentials")
36 .field("consumer_key", &"[REDACTED]")
37 .field("consumer_secret", &"[REDACTED]")
38 .field("access_token", &"[REDACTED]")
39 .field("access_token_secret", &"[REDACTED]")
40 .finish()
41 }
42}
43
44pub struct TwitterPostTool {
49 credentials: TwitterCredentials,
50 client: reqwest::Client,
51 tweet_url: String,
52 media_upload_url: String,
53 media_meta_url: String,
54}
55
56impl TwitterPostTool {
57 pub fn new(credentials: TwitterCredentials) -> Self {
62 Self::try_new(credentials).expect("failed to build reqwest client")
63 }
64
65 pub fn try_new(credentials: TwitterCredentials) -> Result<Self, crate::error::Error> {
70 let client = crate::http::vendor_client_builder()
71 .timeout(std::time::Duration::from_secs(30))
72 .build()
73 .map_err(|e| {
74 crate::error::Error::Agent(format!("failed to build reqwest client: {e}"))
75 })?;
76 Ok(Self {
77 credentials,
78 client,
79 tweet_url: X_API_URL.to_string(),
80 media_upload_url: "https://upload.twitter.com/1.1/media/upload.json".to_string(),
81 media_meta_url: "https://upload.twitter.com/1.1/media/metadata/create.json".to_string(),
82 })
83 }
84}
85
86#[cfg(test)]
87impl TwitterPostTool {
88 pub(crate) fn new_with_base_urls(
90 credentials: TwitterCredentials,
91 tweet_url: String,
92 media_upload_url: String,
93 media_meta_url: String,
94 ) -> Self {
95 let client = crate::http::vendor_client_builder()
96 .timeout(std::time::Duration::from_secs(30))
97 .build()
98 .expect("test client builds");
99 Self {
100 credentials,
101 client,
102 tweet_url,
103 media_upload_url,
104 media_meta_url,
105 }
106 }
107}
108
109fn percent_encode(s: &str) -> String {
111 let mut encoded = String::with_capacity(s.len() * 2);
112 for byte in s.bytes() {
113 match byte {
114 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
115 encoded.push(byte as char);
116 }
117 _ => {
118 let _ = write!(encoded, "%{byte:02X}");
120 }
121 }
122 }
123 encoded
124}
125
126fn build_oauth_header(
128 url: &str,
129 consumer_key: &str,
130 consumer_secret: &str,
131 access_token: &str,
132 access_token_secret: &str,
133 nonce: &str,
134 timestamp: u64,
135) -> Result<String, Error> {
136 let oauth_params = [
137 ("oauth_consumer_key", consumer_key),
138 ("oauth_nonce", nonce),
139 ("oauth_signature_method", "HMAC-SHA1"),
140 ("oauth_timestamp", ×tamp.to_string()),
141 ("oauth_token", access_token),
142 ("oauth_version", "1.0"),
143 ];
144
145 let param_string: String = oauth_params
147 .iter()
148 .map(|(k, v)| format!("{}={}", percent_encode(k), percent_encode(v)))
149 .collect::<Vec<_>>()
150 .join("&");
151
152 let base_string = format!(
154 "POST&{}&{}",
155 percent_encode(url),
156 percent_encode(¶m_string),
157 );
158
159 let signing_key = format!(
161 "{}&{}",
162 percent_encode(consumer_secret),
163 percent_encode(access_token_secret),
164 );
165
166 let mut mac = HmacSha1::new_from_slice(signing_key.as_bytes())
167 .map_err(|e| Error::Agent(format!("HMAC key error: {e}")))?;
168 mac.update(base_string.as_bytes());
169 let signature = base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes());
170
171 Ok(format!(
173 "OAuth oauth_consumer_key=\"{}\", \
174 oauth_nonce=\"{}\", \
175 oauth_signature=\"{}\", \
176 oauth_signature_method=\"HMAC-SHA1\", \
177 oauth_timestamp=\"{}\", \
178 oauth_token=\"{}\", \
179 oauth_version=\"1.0\"",
180 percent_encode(consumer_key),
181 percent_encode(nonce),
182 percent_encode(&signature),
183 timestamp,
184 percent_encode(access_token),
185 ))
186}
187
188impl TwitterPostTool {
189 async fn upload_media(
193 &self,
194 media_url: &str,
195 media_alt_text: Option<&str>,
196 ) -> Result<String, Error> {
197 let bytes_resp = self
199 .client
200 .get(media_url)
201 .send()
202 .await
203 .map_err(|e| Error::Agent(format!("media fetch failed: {e}")))?;
204 let status = bytes_resp.status();
205 if !status.is_success() {
206 return Err(Error::Agent(format!(
207 "media fetch returned status {}",
208 status.as_u16()
209 )));
210 }
211 const MEDIA_LIMIT: usize = 5 * 1024 * 1024;
217 let (body, truncated) = crate::http::read_body_capped(bytes_resp, MEDIA_LIMIT + 1)
218 .await
219 .map_err(|e| Error::Agent(format!("media body read failed: {e}")))?;
220 if truncated || body.len() > MEDIA_LIMIT {
221 return Err(Error::Agent(format!(
222 "media exceeds 5 MB limit (read at least {} bytes)",
223 body.len()
224 )));
225 }
226
227 let timestamp = SystemTime::now()
229 .duration_since(SystemTime::UNIX_EPOCH)
230 .map_err(|e| Error::Agent(format!("system time error: {e}")))?
231 .as_secs();
232 let nonce = format!("{}{}", timestamp, ×tamp.to_string()[..6]);
233
234 let auth_header = build_oauth_header(
235 &self.media_upload_url,
236 &self.credentials.consumer_key,
237 &self.credentials.consumer_secret,
238 &self.credentials.access_token,
239 &self.credentials.access_token_secret,
240 &nonce,
241 timestamp,
242 )?;
243
244 let form = reqwest::multipart::Form::new().part(
245 "media",
246 reqwest::multipart::Part::bytes(body.to_vec()).file_name("media"),
247 );
248 let response = self
249 .client
250 .post(&self.media_upload_url)
251 .header("Authorization", auth_header)
252 .multipart(form)
253 .send()
254 .await
255 .map_err(|e| Error::Agent(format!("media upload failed: {e}")))?;
256 let status = response.status();
257 if !status.is_success() {
258 let body = response.text().await.unwrap_or_default();
259 return Err(Error::Agent(format!(
260 "media upload returned status {}: {body}",
261 status.as_u16()
262 )));
263 }
264 let parsed: serde_json::Value = response
265 .json()
266 .await
267 .map_err(|e| Error::Agent(format!("media upload parse failed: {e}")))?;
268 let media_id_string = parsed
269 .get("media_id_string")
270 .and_then(|v| v.as_str())
271 .ok_or_else(|| Error::Agent("media upload returned no media_id_string".into()))?
272 .to_string();
273
274 if let Some(alt) = media_alt_text {
276 let meta_timestamp = SystemTime::now()
277 .duration_since(SystemTime::UNIX_EPOCH)
278 .map_err(|e| Error::Agent(format!("system time error: {e}")))?
279 .as_secs();
280 let meta_nonce = format!("{}{}", meta_timestamp, &meta_timestamp.to_string()[..6]);
281 let meta_auth = build_oauth_header(
282 &self.media_meta_url,
283 &self.credentials.consumer_key,
284 &self.credentials.consumer_secret,
285 &self.credentials.access_token,
286 &self.credentials.access_token_secret,
287 &meta_nonce,
288 meta_timestamp,
289 )?;
290 let body = json!({
291 "media_id": media_id_string,
292 "alt_text": {"text": alt}
293 });
294 let _ = self
295 .client
296 .post(&self.media_meta_url)
297 .header("Authorization", meta_auth)
298 .json(&body)
299 .send()
300 .await
301 .map_err(|e| Error::Agent(format!("alt-text attach failed: {e}")))?;
302 }
305
306 Ok(media_id_string)
307 }
308}
309
310impl Tool for TwitterPostTool {
311 fn definition(&self) -> ToolDefinition {
312 ToolDefinition {
313 name: "twitter_post".into(),
314 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(),
315 input_schema: json!({
316 "type": "object",
317 "properties": {
318 "text": {
319 "type": "string",
320 "description": "The tweet text to post (max 280 characters)"
321 },
322 "media_url": {
323 "type": "string",
324 "description": "Optional. Public URL of one image to attach (≤5 MB, JPEG/PNG/WebP/GIF). HTTPS recommended."
325 },
326 "media_alt_text": {
327 "type": "string",
328 "description": "Optional. Accessibility description for the image (≤1000 chars). Ignored if media_url is absent.",
329 "maxLength": 1000
330 }
331 },
332 "required": ["text"]
333 }),
334 }
335 }
336
337 fn execute(
338 &self,
339 _ctx: &crate::ExecutionContext,
340 input: serde_json::Value,
341 ) -> Pin<Box<dyn Future<Output = Result<ToolOutput, Error>> + Send + '_>> {
342 Box::pin(async move {
343 let text = input
344 .get("text")
345 .and_then(|v| v.as_str())
346 .ok_or_else(|| Error::Agent("text is required".into()))?;
347
348 if text.is_empty() {
349 return Ok(ToolOutput::error("text must not be empty"));
350 }
351
352 let char_count = text.chars().count();
353 if char_count > MAX_TWEET_LENGTH {
354 return Ok(ToolOutput::error(format!(
355 "Tweet exceeds {MAX_TWEET_LENGTH} characters (got {char_count}). \
356 Please shorten your tweet."
357 )));
358 }
359
360 let media_url = input.get("media_url").and_then(|v| v.as_str());
362 let media_alt_text = input.get("media_alt_text").and_then(|v| v.as_str());
363
364 let media_id_string: Option<String> = if let Some(url) = media_url {
365 match self.upload_media(url, media_alt_text).await {
366 Ok(id) => Some(id),
367 Err(e) => return Ok(ToolOutput::error(format!("media upload failed: {e}"))),
368 }
369 } else {
370 None
371 };
372
373 let timestamp = SystemTime::now()
375 .duration_since(SystemTime::UNIX_EPOCH)
376 .map_err(|e| Error::Agent(format!("system time error: {e}")))?
377 .as_secs();
378
379 let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
381
382 let auth_header = build_oauth_header(
383 &self.tweet_url,
384 &self.credentials.consumer_key,
385 &self.credentials.consumer_secret,
386 &self.credentials.access_token,
387 &self.credentials.access_token_secret,
388 &nonce,
389 timestamp,
390 )?;
391
392 let body = if let Some(ref id) = media_id_string {
393 json!({
394 "text": text,
395 "media": {"media_ids": [id]}
396 })
397 } else {
398 json!({"text": text})
399 };
400
401 let response = self
402 .client
403 .post(&self.tweet_url)
404 .header("Authorization", &auth_header)
405 .header("Content-Type", "application/json")
406 .json(&body)
407 .send()
408 .await
409 .map_err(|e| Error::Agent(format!("X API request failed: {e}")))?;
410
411 let status = response.status();
412 let (body_bytes, _truncated) = crate::http::read_body_capped(response, 256 * 1024)
415 .await
416 .map_err(|e| Error::Agent(format!("Failed to read X API response: {e}")))?;
417 let response_body: serde_json::Value = serde_json::from_slice(&body_bytes)
418 .map_err(|e| Error::Agent(format!("Failed to parse X API response: {e}")))?;
419
420 if !status.is_success() {
421 let detail = response_body
422 .get("detail")
423 .and_then(|v| v.as_str())
424 .or_else(|| response_body.get("title").and_then(|v| v.as_str()))
425 .unwrap_or("Unknown error");
426 return Ok(ToolOutput::error(format!(
427 "X API error (HTTP {}): {detail}",
428 status.as_u16()
429 )));
430 }
431
432 let tweet_id = response_body
434 .get("data")
435 .and_then(|d| d.get("id"))
436 .and_then(|v| v.as_str())
437 .unwrap_or("unknown");
438
439 Ok(ToolOutput::success(format!(
440 "Tweet posted successfully!\n\
441 Tweet ID: {tweet_id}\n\
442 URL: https://x.com/i/status/{tweet_id}\n\
443 Text: {text}"
444 )))
445 })
446 }
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452
453 fn test_credentials() -> TwitterCredentials {
454 TwitterCredentials {
455 consumer_key: "test_consumer_key".into(),
456 consumer_secret: "test_consumer_secret".into(),
457 access_token: "test_access_token".into(),
458 access_token_secret: "test_access_token_secret".into(),
459 }
460 }
461
462 #[test]
463 fn definition_has_correct_name() {
464 let tool = TwitterPostTool::new(test_credentials());
465 assert_eq!(tool.definition().name, "twitter_post");
466 }
467
468 #[test]
469 fn definition_requires_text() {
470 let tool = TwitterPostTool::new(test_credentials());
471 let schema = &tool.definition().input_schema;
472 let required = schema["required"].as_array().unwrap();
473 assert_eq!(required.len(), 1);
474 assert_eq!(required[0], "text");
475 }
476
477 #[test]
478 fn percent_encode_unreserved() {
479 assert_eq!(percent_encode("abc123"), "abc123");
480 assert_eq!(
481 percent_encode("hello-world_test.v2~"),
482 "hello-world_test.v2~"
483 );
484 }
485
486 #[test]
487 fn percent_encode_reserved() {
488 assert_eq!(percent_encode("hello world"), "hello%20world");
489 assert_eq!(percent_encode("a&b=c"), "a%26b%3Dc");
490 assert_eq!(percent_encode("100%"), "100%25");
491 }
492
493 #[test]
494 fn percent_encode_special_chars() {
495 assert_eq!(percent_encode("/"), "%2F");
496 assert_eq!(percent_encode(":"), "%3A");
497 assert_eq!(percent_encode("@"), "%40");
498 }
499
500 #[test]
501 fn build_oauth_header_produces_valid_format() {
502 let header = build_oauth_header(
503 "https://api.twitter.com/2/tweets",
504 "consumer_key",
505 "consumer_secret",
506 "access_token",
507 "access_token_secret",
508 "testnonce123",
509 1234567890,
510 )
511 .unwrap();
512
513 assert!(header.starts_with("OAuth "));
514 assert!(header.contains("oauth_consumer_key=\"consumer_key\""));
515 assert!(header.contains("oauth_nonce=\"testnonce123\""));
516 assert!(header.contains("oauth_signature_method=\"HMAC-SHA1\""));
517 assert!(header.contains("oauth_timestamp=\"1234567890\""));
518 assert!(header.contains("oauth_token=\"access_token\""));
519 assert!(header.contains("oauth_version=\"1.0\""));
520 assert!(header.contains("oauth_signature=\""));
521 }
522
523 #[test]
524 fn build_oauth_header_signature_is_deterministic() {
525 let h1 = build_oauth_header(X_API_URL, "ck", "cs", "at", "ats", "nonce", 1000).unwrap();
526 let h2 = build_oauth_header(X_API_URL, "ck", "cs", "at", "ats", "nonce", 1000).unwrap();
527 assert_eq!(h1, h2);
528 }
529
530 #[test]
531 fn build_oauth_header_different_nonce_produces_different_signature() {
532 let h1 = build_oauth_header(X_API_URL, "ck", "cs", "at", "ats", "nonce1", 1000).unwrap();
533 let h2 = build_oauth_header(X_API_URL, "ck", "cs", "at", "ats", "nonce2", 1000).unwrap();
534 assert_ne!(h1, h2);
535 }
536
537 #[tokio::test]
538 async fn rejects_empty_text() {
539 let tool = TwitterPostTool::new(test_credentials());
540 let result = tool
541 .execute(&crate::ExecutionContext::default(), json!({"text": ""}))
542 .await
543 .unwrap();
544 assert!(result.is_error);
545 assert!(result.content.contains("must not be empty"));
546 }
547
548 #[tokio::test]
549 async fn rejects_text_too_long() {
550 let tool = TwitterPostTool::new(test_credentials());
551 let long = "a".repeat(281);
552 let result = tool
553 .execute(&crate::ExecutionContext::default(), json!({"text": long}))
554 .await
555 .unwrap();
556 assert!(result.is_error);
557 assert!(result.content.contains("exceeds 280 characters"));
558 }
559
560 #[tokio::test]
561 async fn rejects_missing_text() {
562 let tool = TwitterPostTool::new(test_credentials());
563 let result = tool
564 .execute(&crate::ExecutionContext::default(), json!({}))
565 .await;
566 assert!(result.is_err());
567 let err = result.unwrap_err().to_string();
568 assert!(err.contains("text is required"), "got: {err}");
569 }
570
571 #[test]
572 fn credentials_debug_redacts_secrets() {
573 let creds = test_credentials();
574 let debug = format!("{creds:?}");
575 assert!(debug.contains("[REDACTED]"));
576 assert!(!debug.contains("test_consumer_key"));
577 assert!(!debug.contains("test_consumer_secret"));
578 }
579
580 #[tokio::test]
581 async fn accepts_280_chars() {
582 let tool = TwitterPostTool::new(test_credentials());
584 let text = "a".repeat(280);
585 let result = tool
586 .execute(&crate::ExecutionContext::default(), json!({"text": text}))
587 .await;
588 match result {
590 Ok(output) => {
591 if output.is_error {
593 assert!(
594 !output.content.contains("exceeds"),
595 "280 chars should not be rejected: {}",
596 output.content
597 );
598 }
599 }
600 Err(_) => {
601 }
603 }
604 }
605
606 #[tokio::test]
607 async fn post_with_media_url_and_alt_text() {
608 use wiremock::matchers::{method, path as wm_path};
609 use wiremock::{Mock, MockServer, ResponseTemplate};
610
611 let server = MockServer::start().await;
612
613 Mock::given(method("GET"))
615 .and(wm_path("/test-image.png"))
616 .respond_with(
617 ResponseTemplate::new(200)
618 .set_body_bytes(vec![0u8; 1024])
619 .insert_header("Content-Type", "image/png"),
620 )
621 .mount(&server)
622 .await;
623
624 Mock::given(method("POST"))
626 .and(wm_path("/1.1/media/upload.json"))
627 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
628 "media_id_string": "999888777",
629 "media_id": 999888777u64
630 })))
631 .mount(&server)
632 .await;
633
634 Mock::given(method("POST"))
636 .and(wm_path("/1.1/media/metadata/create.json"))
637 .respond_with(ResponseTemplate::new(200))
638 .mount(&server)
639 .await;
640
641 Mock::given(method("POST"))
643 .and(wm_path("/2/tweets"))
644 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
645 "data": {"id": "5555"}
646 })))
647 .mount(&server)
648 .await;
649
650 let media_url = format!("{}/test-image.png", server.uri());
651 let tool = TwitterPostTool::new_with_base_urls(
652 test_credentials(),
653 format!("{}/2/tweets", server.uri()),
654 format!("{}/1.1/media/upload.json", server.uri()),
655 format!("{}/1.1/media/metadata/create.json", server.uri()),
656 );
657 let ctx = crate::ExecutionContext::default();
658 let input = json!({
659 "text": "look at this",
660 "media_url": media_url,
661 "media_alt_text": "a square of zeros"
662 });
663 let result = tool.execute(&ctx, input).await.expect("ok");
664 assert!(!result.is_error);
665 assert!(result.content.contains("5555"));
666 }
667
668 #[tokio::test]
669 async fn post_text_only_still_works_without_media_fields() {
670 use wiremock::matchers::{method, path as wm_path};
671 use wiremock::{Mock, MockServer, ResponseTemplate};
672
673 let server = MockServer::start().await;
674 Mock::given(method("POST"))
675 .and(wm_path("/2/tweets"))
676 .respond_with(
677 ResponseTemplate::new(201)
678 .set_body_json(serde_json::json!({"data": {"id": "111"}})),
679 )
680 .mount(&server)
681 .await;
682
683 let tool = TwitterPostTool::new_with_base_urls(
684 test_credentials(),
685 format!("{}/2/tweets", server.uri()),
686 format!("{}/1.1/media/upload.json", server.uri()),
687 format!("{}/1.1/media/metadata/create.json", server.uri()),
688 );
689 let ctx = crate::ExecutionContext::default();
690 let input = json!({"text": "no media"});
691 let result = tool.execute(&ctx, input).await.expect("ok");
692 assert!(!result.is_error);
693 }
694
695 #[tokio::test]
696 async fn post_rejects_oversized_media() {
697 use wiremock::matchers::{method, path as wm_path};
698 use wiremock::{Mock, MockServer, ResponseTemplate};
699
700 let server = MockServer::start().await;
701 Mock::given(method("GET"))
702 .and(wm_path("/big.png"))
703 .respond_with(
704 ResponseTemplate::new(200)
705 .set_body_bytes(vec![0u8; 6 * 1024 * 1024])
706 .insert_header("Content-Type", "image/png"),
707 )
708 .mount(&server)
709 .await;
710
711 let tool = TwitterPostTool::new_with_base_urls(
712 test_credentials(),
713 format!("{}/2/tweets", server.uri()),
714 format!("{}/1.1/media/upload.json", server.uri()),
715 format!("{}/1.1/media/metadata/create.json", server.uri()),
716 );
717 let ctx = crate::ExecutionContext::default();
718 let media_url = format!("{}/big.png", server.uri());
719 let input = json!({
720 "text": "won't fit",
721 "media_url": media_url
722 });
723 let result = tool
724 .execute(&ctx, input)
725 .await
726 .expect("Tool::execute returns Ok");
727 assert!(result.is_error);
728 assert!(result.content.contains("5 MB") || result.content.contains("exceeds"));
729 }
730
731 #[tokio::test]
732 async fn post_handles_404_on_media_url() {
733 use wiremock::matchers::{method, path as wm_path};
734 use wiremock::{Mock, MockServer, ResponseTemplate};
735
736 let server = MockServer::start().await;
737 Mock::given(method("GET"))
738 .and(wm_path("/missing.png"))
739 .respond_with(ResponseTemplate::new(404))
740 .mount(&server)
741 .await;
742
743 let tool = TwitterPostTool::new_with_base_urls(
744 test_credentials(),
745 format!("{}/2/tweets", server.uri()),
746 format!("{}/1.1/media/upload.json", server.uri()),
747 format!("{}/1.1/media/metadata/create.json", server.uri()),
748 );
749 let ctx = crate::ExecutionContext::default();
750 let media_url = format!("{}/missing.png", server.uri());
751 let input = json!({
752 "text": "broken link",
753 "media_url": media_url
754 });
755 let result = tool
756 .execute(&ctx, input)
757 .await
758 .expect("Tool::execute returns Ok");
759 assert!(result.is_error);
760 assert!(result.content.contains("404") || result.content.contains("media fetch"));
761 }
762}