Skip to main content

tuitbot_core/x_api/
mod.rs

1//! X API v2 client, authentication, and tier detection.
2//!
3//! Provides a trait-based client abstraction for all X API operations,
4//! OAuth 2.0 PKCE authentication with token management, and API tier
5//! detection for adaptive behavior.
6
7pub mod auth;
8pub mod client;
9pub mod local_mode;
10pub mod media;
11pub mod null_client;
12pub mod retry;
13pub mod scopes;
14pub mod scraper_health;
15pub mod tier;
16pub mod types;
17
18pub use client::XApiHttpClient;
19pub use local_mode::session::ScraperSession;
20pub use local_mode::LocalModeXClient;
21pub use null_client::NullXApiClient;
22pub use scraper_health::{new_scraper_health, ScraperHealth, ScraperHealthSnapshot, ScraperState};
23pub use types::*;
24
25use std::path::Path;
26use std::sync::Arc;
27
28use crate::config::XApiConfig;
29use crate::error::XApiError;
30
31/// Create a local-mode X API client if `provider_backend = "scraper"`.
32///
33/// Returns `Some(Arc<dyn XApiClient>)` for scraper backend, `None` for
34/// official backend (caller must construct `XApiHttpClient` with OAuth tokens).
35///
36/// When `data_dir` is provided, attempts to load a cookie-auth session from
37/// `scraper_session.json` in that directory to enable posting.
38pub async fn create_local_client(config: &XApiConfig) -> Option<Arc<dyn XApiClient>> {
39    create_local_client_with_data_dir(config, None).await
40}
41
42/// Create a local-mode client with an optional data directory for cookie-auth.
43pub async fn create_local_client_with_data_dir(
44    config: &XApiConfig,
45    data_dir: Option<&Path>,
46) -> Option<Arc<dyn XApiClient>> {
47    if config.provider_backend == "scraper" {
48        let client = match data_dir {
49            Some(dir) => LocalModeXClient::with_session(config.scraper_allow_mutations, dir).await,
50            None => LocalModeXClient::new(config.scraper_allow_mutations),
51        };
52        Some(Arc::new(client))
53    } else {
54        None
55    }
56}
57
58/// Trait abstracting all X API v2 operations.
59///
60/// Implementations include `XApiHttpClient` for real API calls and
61/// mock implementations for testing.
62#[async_trait::async_trait]
63pub trait XApiClient: Send + Sync {
64    /// Search recent tweets matching the given query.
65    ///
66    /// Returns up to `max_results` tweets. If `since_id` is provided,
67    /// only returns tweets newer than that ID.
68    async fn search_tweets(
69        &self,
70        query: &str,
71        max_results: u32,
72        since_id: Option<&str>,
73        pagination_token: Option<&str>,
74    ) -> Result<SearchResponse, XApiError>;
75
76    /// Get mentions for the authenticated user.
77    ///
78    /// If `since_id` is provided, only returns mentions newer than that ID.
79    async fn get_mentions(
80        &self,
81        user_id: &str,
82        since_id: Option<&str>,
83        pagination_token: Option<&str>,
84    ) -> Result<MentionResponse, XApiError>;
85
86    /// Post a new tweet.
87    async fn post_tweet(&self, text: &str) -> Result<PostedTweet, XApiError>;
88
89    /// Reply to an existing tweet.
90    async fn reply_to_tweet(
91        &self,
92        text: &str,
93        in_reply_to_id: &str,
94    ) -> Result<PostedTweet, XApiError>;
95
96    /// Get a single tweet by ID.
97    async fn get_tweet(&self, tweet_id: &str) -> Result<Tweet, XApiError>;
98
99    /// Get the authenticated user's profile.
100    async fn get_me(&self) -> Result<User, XApiError>;
101
102    /// Get recent tweets from a specific user.
103    async fn get_user_tweets(
104        &self,
105        user_id: &str,
106        max_results: u32,
107        pagination_token: Option<&str>,
108    ) -> Result<SearchResponse, XApiError>;
109
110    /// Look up a user by their username.
111    async fn get_user_by_username(&self, username: &str) -> Result<User, XApiError>;
112
113    /// Upload media to X API for attaching to tweets.
114    ///
115    /// Default implementation returns an error — override in concrete clients.
116    async fn upload_media(
117        &self,
118        _data: &[u8],
119        _media_type: MediaType,
120    ) -> Result<MediaId, XApiError> {
121        Err(XApiError::MediaUploadError {
122            message: "upload_media not implemented".to_string(),
123        })
124    }
125
126    /// Post a new tweet with media attachments.
127    ///
128    /// Default delegates to `post_tweet` (ignoring media) for backward compat.
129    async fn post_tweet_with_media(
130        &self,
131        text: &str,
132        _media_ids: &[String],
133    ) -> Result<PostedTweet, XApiError> {
134        self.post_tweet(text).await
135    }
136
137    /// Reply to an existing tweet with media attachments.
138    ///
139    /// Default delegates to `reply_to_tweet` (ignoring media) for backward compat.
140    async fn reply_to_tweet_with_media(
141        &self,
142        text: &str,
143        in_reply_to_id: &str,
144        _media_ids: &[String],
145    ) -> Result<PostedTweet, XApiError> {
146        self.reply_to_tweet(text, in_reply_to_id).await
147    }
148
149    /// Post a quote tweet referencing another tweet.
150    async fn quote_tweet(
151        &self,
152        _text: &str,
153        _quoted_tweet_id: &str,
154    ) -> Result<PostedTweet, XApiError> {
155        Err(XApiError::ApiError {
156            status: 0,
157            message: "not implemented".to_string(),
158        })
159    }
160
161    /// Like a tweet on behalf of the authenticated user.
162    async fn like_tweet(&self, _user_id: &str, _tweet_id: &str) -> Result<bool, XApiError> {
163        Err(XApiError::ApiError {
164            status: 0,
165            message: "not implemented".to_string(),
166        })
167    }
168
169    /// Follow a user on behalf of the authenticated user.
170    async fn follow_user(&self, _user_id: &str, _target_user_id: &str) -> Result<bool, XApiError> {
171        Err(XApiError::ApiError {
172            status: 0,
173            message: "not implemented".to_string(),
174        })
175    }
176
177    /// Unfollow a user on behalf of the authenticated user.
178    async fn unfollow_user(
179        &self,
180        _user_id: &str,
181        _target_user_id: &str,
182    ) -> Result<bool, XApiError> {
183        Err(XApiError::ApiError {
184            status: 0,
185            message: "not implemented".to_string(),
186        })
187    }
188
189    /// Retweet a tweet on behalf of the authenticated user.
190    async fn retweet(&self, _user_id: &str, _tweet_id: &str) -> Result<bool, XApiError> {
191        Err(XApiError::ApiError {
192            status: 0,
193            message: "not implemented".to_string(),
194        })
195    }
196
197    /// Undo a retweet on behalf of the authenticated user.
198    async fn unretweet(&self, _user_id: &str, _tweet_id: &str) -> Result<bool, XApiError> {
199        Err(XApiError::ApiError {
200            status: 0,
201            message: "not implemented".to_string(),
202        })
203    }
204
205    /// Delete a tweet by its ID.
206    async fn delete_tweet(&self, _tweet_id: &str) -> Result<bool, XApiError> {
207        Err(XApiError::ApiError {
208            status: 0,
209            message: "not implemented".to_string(),
210        })
211    }
212
213    /// Get the authenticated user's home timeline (reverse chronological).
214    async fn get_home_timeline(
215        &self,
216        _user_id: &str,
217        _max_results: u32,
218        _pagination_token: Option<&str>,
219    ) -> Result<SearchResponse, XApiError> {
220        Err(XApiError::ApiError {
221            status: 0,
222            message: "not implemented".to_string(),
223        })
224    }
225
226    /// Unlike a tweet on behalf of the authenticated user.
227    async fn unlike_tweet(&self, _user_id: &str, _tweet_id: &str) -> Result<bool, XApiError> {
228        Err(XApiError::ApiError {
229            status: 0,
230            message: "not implemented".to_string(),
231        })
232    }
233
234    /// Get followers of a user.
235    async fn get_followers(
236        &self,
237        _user_id: &str,
238        _max_results: u32,
239        _pagination_token: Option<&str>,
240    ) -> Result<UsersResponse, XApiError> {
241        Err(XApiError::ApiError {
242            status: 0,
243            message: "not implemented".to_string(),
244        })
245    }
246
247    /// Get accounts a user is following.
248    async fn get_following(
249        &self,
250        _user_id: &str,
251        _max_results: u32,
252        _pagination_token: Option<&str>,
253    ) -> Result<UsersResponse, XApiError> {
254        Err(XApiError::ApiError {
255            status: 0,
256            message: "not implemented".to_string(),
257        })
258    }
259
260    /// Get a user by their ID.
261    async fn get_user_by_id(&self, _user_id: &str) -> Result<User, XApiError> {
262        Err(XApiError::ApiError {
263            status: 0,
264            message: "not implemented".to_string(),
265        })
266    }
267
268    /// Get tweets liked by a user.
269    async fn get_liked_tweets(
270        &self,
271        _user_id: &str,
272        _max_results: u32,
273        _pagination_token: Option<&str>,
274    ) -> Result<SearchResponse, XApiError> {
275        Err(XApiError::ApiError {
276            status: 0,
277            message: "not implemented".to_string(),
278        })
279    }
280
281    /// Get the authenticated user's bookmarks.
282    async fn get_bookmarks(
283        &self,
284        _user_id: &str,
285        _max_results: u32,
286        _pagination_token: Option<&str>,
287    ) -> Result<SearchResponse, XApiError> {
288        Err(XApiError::ApiError {
289            status: 0,
290            message: "not implemented".to_string(),
291        })
292    }
293
294    /// Bookmark a tweet.
295    async fn bookmark_tweet(&self, _user_id: &str, _tweet_id: &str) -> Result<bool, XApiError> {
296        Err(XApiError::ApiError {
297            status: 0,
298            message: "not implemented".to_string(),
299        })
300    }
301
302    /// Remove a bookmark.
303    async fn unbookmark_tweet(&self, _user_id: &str, _tweet_id: &str) -> Result<bool, XApiError> {
304        Err(XApiError::ApiError {
305            status: 0,
306            message: "not implemented".to_string(),
307        })
308    }
309
310    /// Get multiple users by their IDs.
311    async fn get_users_by_ids(&self, _user_ids: &[&str]) -> Result<UsersResponse, XApiError> {
312        Err(XApiError::ApiError {
313            status: 0,
314            message: "not implemented".to_string(),
315        })
316    }
317
318    /// Get users who liked a specific tweet.
319    async fn get_tweet_liking_users(
320        &self,
321        _tweet_id: &str,
322        _max_results: u32,
323        _pagination_token: Option<&str>,
324    ) -> Result<UsersResponse, XApiError> {
325        Err(XApiError::ApiError {
326            status: 0,
327            message: "not implemented".to_string(),
328        })
329    }
330
331    /// Execute a raw HTTP request against the X API.
332    ///
333    /// The caller is responsible for constructing a valid, pre-validated URL.
334    /// The implementation adds Bearer token authentication automatically.
335    /// Returns the raw HTTP response including status, selected headers, and body text.
336    ///
337    /// `method` must be one of: `GET`, `POST`, `PUT`, `DELETE`.
338    async fn raw_request(
339        &self,
340        _method: &str,
341        _url: &str,
342        _query: Option<&[(String, String)]>,
343        _body: Option<&str>,
344        _headers: Option<&[(String, String)]>,
345    ) -> Result<RawApiResponse, XApiError> {
346        Err(XApiError::ApiError {
347            status: 0,
348            message: "raw_request not implemented".to_string(),
349        })
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    /// Minimal concrete impl for testing default trait methods.
358    struct StubClient;
359
360    #[async_trait::async_trait]
361    impl XApiClient for StubClient {
362        async fn search_tweets(
363            &self,
364            _q: &str,
365            _m: u32,
366            _s: Option<&str>,
367            _p: Option<&str>,
368        ) -> Result<SearchResponse, XApiError> {
369            Ok(SearchResponse {
370                data: vec![],
371                includes: None,
372                meta: SearchMeta {
373                    newest_id: None,
374                    oldest_id: None,
375                    result_count: 0,
376                    next_token: None,
377                },
378            })
379        }
380        async fn get_mentions(
381            &self,
382            _u: &str,
383            _s: Option<&str>,
384            _p: Option<&str>,
385        ) -> Result<MentionResponse, XApiError> {
386            Ok(MentionResponse {
387                data: vec![],
388                includes: None,
389                meta: SearchMeta {
390                    newest_id: None,
391                    oldest_id: None,
392                    result_count: 0,
393                    next_token: None,
394                },
395            })
396        }
397        async fn post_tweet(&self, text: &str) -> Result<PostedTweet, XApiError> {
398            Ok(PostedTweet {
399                id: "stub_id".to_string(),
400                text: text.to_string(),
401            })
402        }
403        async fn reply_to_tweet(
404            &self,
405            text: &str,
406            _reply_to: &str,
407        ) -> Result<PostedTweet, XApiError> {
408            Ok(PostedTweet {
409                id: "reply_id".to_string(),
410                text: text.to_string(),
411            })
412        }
413        async fn get_tweet(&self, _id: &str) -> Result<Tweet, XApiError> {
414            Err(XApiError::ApiError {
415                status: 404,
416                message: "not found".into(),
417            })
418        }
419        async fn get_me(&self) -> Result<User, XApiError> {
420            Ok(User {
421                id: "me".into(),
422                username: "stub".into(),
423                name: "Stub".into(),
424                profile_image_url: None,
425                description: None,
426                location: None,
427                url: None,
428                public_metrics: UserMetrics::default(),
429            })
430        }
431        async fn get_user_tweets(
432            &self,
433            _u: &str,
434            _m: u32,
435            _p: Option<&str>,
436        ) -> Result<SearchResponse, XApiError> {
437            Ok(SearchResponse {
438                data: vec![],
439                includes: None,
440                meta: SearchMeta {
441                    newest_id: None,
442                    oldest_id: None,
443                    result_count: 0,
444                    next_token: None,
445                },
446            })
447        }
448        async fn get_user_by_username(&self, _u: &str) -> Result<User, XApiError> {
449            Err(XApiError::ApiError {
450                status: 404,
451                message: "not found".into(),
452            })
453        }
454    }
455
456    // ── Default trait method tests ──────────────────────────────
457
458    #[tokio::test]
459    async fn upload_media_default_returns_error() {
460        let client = StubClient;
461        let result = client.upload_media(b"data", MediaType::Gif).await;
462        assert!(matches!(result, Err(XApiError::MediaUploadError { .. })));
463    }
464
465    #[tokio::test]
466    async fn post_tweet_with_media_delegates_to_post_tweet() {
467        let client = StubClient;
468        let result = client
469            .post_tweet_with_media("hello", &["media1".to_string()])
470            .await
471            .unwrap();
472        assert_eq!(result.text, "hello");
473        assert_eq!(result.id, "stub_id");
474    }
475
476    #[tokio::test]
477    async fn reply_to_tweet_with_media_delegates_to_reply() {
478        let client = StubClient;
479        let result = client
480            .reply_to_tweet_with_media("reply text", "tweet_123", &["m1".to_string()])
481            .await
482            .unwrap();
483        assert_eq!(result.text, "reply text");
484        assert_eq!(result.id, "reply_id");
485    }
486
487    #[tokio::test]
488    async fn quote_tweet_default_not_implemented() {
489        let client = StubClient;
490        let result = client.quote_tweet("text", "quoted_id").await;
491        assert!(result.is_err());
492    }
493
494    #[tokio::test]
495    async fn like_tweet_default_not_implemented() {
496        let client = StubClient;
497        let result = client.like_tweet("user1", "tweet1").await;
498        assert!(result.is_err());
499    }
500
501    #[tokio::test]
502    async fn follow_user_default_not_implemented() {
503        let client = StubClient;
504        let result = client.follow_user("u1", "u2").await;
505        assert!(result.is_err());
506    }
507
508    #[tokio::test]
509    async fn unfollow_user_default_not_implemented() {
510        let client = StubClient;
511        let result = client.unfollow_user("u1", "u2").await;
512        assert!(result.is_err());
513    }
514
515    #[tokio::test]
516    async fn retweet_default_not_implemented() {
517        let client = StubClient;
518        let result = client.retweet("u1", "t1").await;
519        assert!(result.is_err());
520    }
521
522    #[tokio::test]
523    async fn unretweet_default_not_implemented() {
524        let client = StubClient;
525        let result = client.unretweet("u1", "t1").await;
526        assert!(result.is_err());
527    }
528
529    #[tokio::test]
530    async fn delete_tweet_default_not_implemented() {
531        let client = StubClient;
532        let result = client.delete_tweet("t1").await;
533        assert!(result.is_err());
534    }
535
536    #[tokio::test]
537    async fn get_home_timeline_default_not_implemented() {
538        let client = StubClient;
539        let result = client.get_home_timeline("u1", 10, None).await;
540        assert!(result.is_err());
541    }
542
543    #[tokio::test]
544    async fn unlike_tweet_default_not_implemented() {
545        let client = StubClient;
546        let result = client.unlike_tweet("u1", "t1").await;
547        assert!(result.is_err());
548    }
549
550    #[tokio::test]
551    async fn get_followers_default_not_implemented() {
552        let client = StubClient;
553        let result = client.get_followers("u1", 10, None).await;
554        assert!(result.is_err());
555    }
556
557    #[tokio::test]
558    async fn get_following_default_not_implemented() {
559        let client = StubClient;
560        let result = client.get_following("u1", 10, None).await;
561        assert!(result.is_err());
562    }
563
564    #[tokio::test]
565    async fn get_user_by_id_default_not_implemented() {
566        let client = StubClient;
567        let result = client.get_user_by_id("u1").await;
568        assert!(result.is_err());
569    }
570
571    #[tokio::test]
572    async fn get_liked_tweets_default_not_implemented() {
573        let client = StubClient;
574        let result = client.get_liked_tweets("u1", 10, None).await;
575        assert!(result.is_err());
576    }
577
578    #[tokio::test]
579    async fn get_bookmarks_default_not_implemented() {
580        let client = StubClient;
581        let result = client.get_bookmarks("u1", 10, None).await;
582        assert!(result.is_err());
583    }
584
585    #[tokio::test]
586    async fn bookmark_tweet_default_not_implemented() {
587        let client = StubClient;
588        let result = client.bookmark_tweet("u1", "t1").await;
589        assert!(result.is_err());
590    }
591
592    #[tokio::test]
593    async fn unbookmark_tweet_default_not_implemented() {
594        let client = StubClient;
595        let result = client.unbookmark_tweet("u1", "t1").await;
596        assert!(result.is_err());
597    }
598
599    #[tokio::test]
600    async fn get_users_by_ids_default_not_implemented() {
601        let client = StubClient;
602        let result = client.get_users_by_ids(&["u1", "u2"]).await;
603        assert!(result.is_err());
604    }
605
606    #[tokio::test]
607    async fn get_tweet_liking_users_default_not_implemented() {
608        let client = StubClient;
609        let result = client.get_tweet_liking_users("t1", 10, None).await;
610        assert!(result.is_err());
611    }
612
613    #[tokio::test]
614    async fn raw_request_default_not_implemented() {
615        let client = StubClient;
616        let result = client.raw_request("GET", "/test", None, None, None).await;
617        assert!(result.is_err());
618    }
619
620    // ── create_local_client tests ───────────────────────────────
621
622    #[tokio::test]
623    async fn create_local_client_non_scraper_returns_none() {
624        let config = XApiConfig::default();
625        let result = create_local_client(&config).await;
626        assert!(result.is_none());
627    }
628
629    #[tokio::test]
630    async fn create_local_client_scraper_returns_some() {
631        let mut config = XApiConfig::default();
632        config.provider_backend = "scraper".to_string();
633        let result = create_local_client(&config).await;
634        assert!(result.is_some());
635    }
636
637    #[tokio::test]
638    async fn create_local_client_with_data_dir_non_scraper() {
639        let config = XApiConfig::default();
640        let dir = tempfile::tempdir().expect("temp dir");
641        let result = create_local_client_with_data_dir(&config, Some(dir.path())).await;
642        assert!(result.is_none());
643    }
644
645    #[tokio::test]
646    async fn create_local_client_with_data_dir_scraper() {
647        let mut config = XApiConfig::default();
648        config.provider_backend = "scraper".to_string();
649        let dir = tempfile::tempdir().expect("temp dir");
650        let result = create_local_client_with_data_dir(&config, Some(dir.path())).await;
651        assert!(result.is_some());
652    }
653
654    #[tokio::test]
655    async fn create_local_client_with_data_dir_none() {
656        let mut config = XApiConfig::default();
657        config.provider_backend = "scraper".to_string();
658        let result = create_local_client_with_data_dir(&config, None).await;
659        assert!(result.is_some());
660    }
661}