Skip to main content

heartbit_core/tool/builtins/
twitter_post.rs

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/// Per-tenant X/Twitter credentials for OAuth 1.0a signing.
21#[derive(Clone)]
22pub struct TwitterCredentials {
23    /// OAuth 1.0a consumer (app) key.
24    pub consumer_key: String,
25    /// OAuth 1.0a consumer (app) secret.
26    pub consumer_secret: String,
27    /// Per-user access token.
28    pub access_token: String,
29    /// Per-user access token secret.
30    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
44/// Builtin tool for posting tweets to X/Twitter via API v2.
45///
46/// Uses OAuth 1.0a signing with per-tenant credentials injected at runtime.
47/// Only instantiated when `TwitterCredentials` are provided (multi-tenant).
48pub 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    /// Create a `TwitterPostTool` with the given credentials.
58    ///
59    /// Panics if the HTTP client cannot be built. Use [`TwitterPostTool::try_new`]
60    /// if you need to handle the error.
61    pub fn new(credentials: TwitterCredentials) -> Self {
62        Self::try_new(credentials).expect("failed to build reqwest client")
63    }
64
65    /// Create a `TwitterPostTool` with the given credentials, returning `Err` on failure.
66    ///
67    /// Returns `Err` if the underlying HTTP client cannot be constructed
68    /// (e.g., TLS initialisation failure).
69    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    /// Test-only constructor that allows injecting endpoint URLs for wiremock.
89    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
109/// Percent-encode a string per RFC 5849 (OAuth 1.0a).
110fn 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                // write! to String is infallible — avoids temporary allocation from format!
119                let _ = write!(encoded, "%{byte:02X}");
120            }
121        }
122    }
123    encoded
124}
125
126/// Build the OAuth 1.0a Authorization header for a POST request.
127fn 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", &timestamp.to_string()),
141        ("oauth_token", access_token),
142        ("oauth_version", "1.0"),
143    ];
144
145    // Build parameter string (sorted by key)
146    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    // Build signature base string: METHOD&url&params
153    let base_string = format!(
154        "POST&{}&{}",
155        percent_encode(url),
156        percent_encode(&param_string),
157    );
158
159    // Sign with HMAC-SHA1
160    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    // Build Authorization header
172    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    /// Fetch image bytes from `media_url`, upload to X v1.1 media endpoint, and
190    /// (optionally) attach alt text. Returns the `media_id_string` to be referenced
191    /// in the tweet POST.
192    async fn upload_media(
193        &self,
194        media_url: &str,
195        media_alt_text: Option<&str>,
196    ) -> Result<String, Error> {
197        // Step A: Fetch the image bytes (HTTP GET, ≤5 MB)
198        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        // SECURITY (F-NET-1): cap the media body. The previous `.bytes().await`
212        // buffered the ENTIRE response before checking the 5 MB limit, so a
213        // hostile (or compromised) media_url serving a multi-GB body would OOM
214        // the process. `read_body_capped` streams and stops at the cap. We read
215        // one byte past the limit so an over-size body is detected (truncated).
216        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        // Step B: Upload to X v1.1 media endpoint via multipart/form-data
228        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, &timestamp.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        // Step C (optional): attach alt text via metadata/create
275        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            // Don't fail if alt-text attach fails — the media itself is already up
303            // and the tweet can still post.
304        }
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            // Optional media handling: upload first, then attach to the tweet body.
361            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            // Generate OAuth nonce and timestamp
374            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            // UUID v4 provides cryptographically random nonce (required by RFC 5849)
380            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            // SECURITY (F-NET-1): cap response body. Tweet responses are tiny
413            // (well under 1 MiB) — 256 KiB is generous and bounds memory.
414            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            // Extract tweet ID from response
433            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        // 280 chars should pass validation (will fail at HTTP level, but that's expected)
583        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        // Should not be a validation error — will fail at HTTP level
589        match result {
590            Ok(output) => {
591                // Network error is fine, but should NOT be a validation error
592                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                // Network error is expected with fake credentials
602            }
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        // Stub the media bytes URL (the image)
614        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        // Stub the media upload endpoint
625        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        // Stub the metadata/create endpoint (alt text)
635        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        // Stub the tweet POST endpoint
642        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}