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 scopes;
12pub mod tier;
13pub mod types;
14
15pub use client::XApiHttpClient;
16pub use local_mode::session::ScraperSession;
17pub use local_mode::LocalModeXClient;
18pub use types::*;
19
20use std::path::Path;
21use std::sync::Arc;
22
23use crate::config::XApiConfig;
24use crate::error::XApiError;
25
26/// Create a local-mode X API client if `provider_backend = "scraper"`.
27///
28/// Returns `Some(Arc<dyn XApiClient>)` for scraper backend, `None` for
29/// official backend (caller must construct `XApiHttpClient` with OAuth tokens).
30///
31/// When `data_dir` is provided, attempts to load a cookie-auth session from
32/// `scraper_session.json` in that directory to enable posting.
33pub async fn create_local_client(config: &XApiConfig) -> Option<Arc<dyn XApiClient>> {
34    create_local_client_with_data_dir(config, None).await
35}
36
37/// Create a local-mode client with an optional data directory for cookie-auth.
38pub async fn create_local_client_with_data_dir(
39    config: &XApiConfig,
40    data_dir: Option<&Path>,
41) -> Option<Arc<dyn XApiClient>> {
42    if config.provider_backend == "scraper" {
43        let client = match data_dir {
44            Some(dir) => LocalModeXClient::with_session(config.scraper_allow_mutations, dir).await,
45            None => LocalModeXClient::new(config.scraper_allow_mutations),
46        };
47        Some(Arc::new(client))
48    } else {
49        None
50    }
51}
52
53/// Trait abstracting all X API v2 operations.
54///
55/// Implementations include `XApiHttpClient` for real API calls and
56/// mock implementations for testing.
57#[async_trait::async_trait]
58pub trait XApiClient: Send + Sync {
59    /// Search recent tweets matching the given query.
60    ///
61    /// Returns up to `max_results` tweets. If `since_id` is provided,
62    /// only returns tweets newer than that ID.
63    async fn search_tweets(
64        &self,
65        query: &str,
66        max_results: u32,
67        since_id: Option<&str>,
68        pagination_token: Option<&str>,
69    ) -> Result<SearchResponse, XApiError>;
70
71    /// Get mentions for the authenticated user.
72    ///
73    /// If `since_id` is provided, only returns mentions newer than that ID.
74    async fn get_mentions(
75        &self,
76        user_id: &str,
77        since_id: Option<&str>,
78        pagination_token: Option<&str>,
79    ) -> Result<MentionResponse, XApiError>;
80
81    /// Post a new tweet.
82    async fn post_tweet(&self, text: &str) -> Result<PostedTweet, XApiError>;
83
84    /// Reply to an existing tweet.
85    async fn reply_to_tweet(
86        &self,
87        text: &str,
88        in_reply_to_id: &str,
89    ) -> Result<PostedTweet, XApiError>;
90
91    /// Get a single tweet by ID.
92    async fn get_tweet(&self, tweet_id: &str) -> Result<Tweet, XApiError>;
93
94    /// Get the authenticated user's profile.
95    async fn get_me(&self) -> Result<User, XApiError>;
96
97    /// Get recent tweets from a specific user.
98    async fn get_user_tweets(
99        &self,
100        user_id: &str,
101        max_results: u32,
102        pagination_token: Option<&str>,
103    ) -> Result<SearchResponse, XApiError>;
104
105    /// Look up a user by their username.
106    async fn get_user_by_username(&self, username: &str) -> Result<User, XApiError>;
107
108    /// Upload media to X API for attaching to tweets.
109    ///
110    /// Default implementation returns an error — override in concrete clients.
111    async fn upload_media(
112        &self,
113        _data: &[u8],
114        _media_type: MediaType,
115    ) -> Result<MediaId, XApiError> {
116        Err(XApiError::MediaUploadError {
117            message: "upload_media not implemented".to_string(),
118        })
119    }
120
121    /// Post a new tweet with media attachments.
122    ///
123    /// Default delegates to `post_tweet` (ignoring media) for backward compat.
124    async fn post_tweet_with_media(
125        &self,
126        text: &str,
127        _media_ids: &[String],
128    ) -> Result<PostedTweet, XApiError> {
129        self.post_tweet(text).await
130    }
131
132    /// Reply to an existing tweet with media attachments.
133    ///
134    /// Default delegates to `reply_to_tweet` (ignoring media) for backward compat.
135    async fn reply_to_tweet_with_media(
136        &self,
137        text: &str,
138        in_reply_to_id: &str,
139        _media_ids: &[String],
140    ) -> Result<PostedTweet, XApiError> {
141        self.reply_to_tweet(text, in_reply_to_id).await
142    }
143
144    /// Post a quote tweet referencing another tweet.
145    async fn quote_tweet(
146        &self,
147        _text: &str,
148        _quoted_tweet_id: &str,
149    ) -> Result<PostedTweet, XApiError> {
150        Err(XApiError::ApiError {
151            status: 0,
152            message: "not implemented".to_string(),
153        })
154    }
155
156    /// Like a tweet on behalf of the authenticated user.
157    async fn like_tweet(&self, _user_id: &str, _tweet_id: &str) -> Result<bool, XApiError> {
158        Err(XApiError::ApiError {
159            status: 0,
160            message: "not implemented".to_string(),
161        })
162    }
163
164    /// Follow a user on behalf of the authenticated user.
165    async fn follow_user(&self, _user_id: &str, _target_user_id: &str) -> Result<bool, XApiError> {
166        Err(XApiError::ApiError {
167            status: 0,
168            message: "not implemented".to_string(),
169        })
170    }
171
172    /// Unfollow a user on behalf of the authenticated user.
173    async fn unfollow_user(
174        &self,
175        _user_id: &str,
176        _target_user_id: &str,
177    ) -> Result<bool, XApiError> {
178        Err(XApiError::ApiError {
179            status: 0,
180            message: "not implemented".to_string(),
181        })
182    }
183
184    /// Retweet a tweet on behalf of the authenticated user.
185    async fn retweet(&self, _user_id: &str, _tweet_id: &str) -> Result<bool, XApiError> {
186        Err(XApiError::ApiError {
187            status: 0,
188            message: "not implemented".to_string(),
189        })
190    }
191
192    /// Undo a retweet on behalf of the authenticated user.
193    async fn unretweet(&self, _user_id: &str, _tweet_id: &str) -> Result<bool, XApiError> {
194        Err(XApiError::ApiError {
195            status: 0,
196            message: "not implemented".to_string(),
197        })
198    }
199
200    /// Delete a tweet by its ID.
201    async fn delete_tweet(&self, _tweet_id: &str) -> Result<bool, XApiError> {
202        Err(XApiError::ApiError {
203            status: 0,
204            message: "not implemented".to_string(),
205        })
206    }
207
208    /// Get the authenticated user's home timeline (reverse chronological).
209    async fn get_home_timeline(
210        &self,
211        _user_id: &str,
212        _max_results: u32,
213        _pagination_token: Option<&str>,
214    ) -> Result<SearchResponse, XApiError> {
215        Err(XApiError::ApiError {
216            status: 0,
217            message: "not implemented".to_string(),
218        })
219    }
220
221    /// Unlike a tweet on behalf of the authenticated user.
222    async fn unlike_tweet(&self, _user_id: &str, _tweet_id: &str) -> Result<bool, XApiError> {
223        Err(XApiError::ApiError {
224            status: 0,
225            message: "not implemented".to_string(),
226        })
227    }
228
229    /// Get followers of a user.
230    async fn get_followers(
231        &self,
232        _user_id: &str,
233        _max_results: u32,
234        _pagination_token: Option<&str>,
235    ) -> Result<UsersResponse, XApiError> {
236        Err(XApiError::ApiError {
237            status: 0,
238            message: "not implemented".to_string(),
239        })
240    }
241
242    /// Get accounts a user is following.
243    async fn get_following(
244        &self,
245        _user_id: &str,
246        _max_results: u32,
247        _pagination_token: Option<&str>,
248    ) -> Result<UsersResponse, XApiError> {
249        Err(XApiError::ApiError {
250            status: 0,
251            message: "not implemented".to_string(),
252        })
253    }
254
255    /// Get a user by their ID.
256    async fn get_user_by_id(&self, _user_id: &str) -> Result<User, XApiError> {
257        Err(XApiError::ApiError {
258            status: 0,
259            message: "not implemented".to_string(),
260        })
261    }
262
263    /// Get tweets liked by a user.
264    async fn get_liked_tweets(
265        &self,
266        _user_id: &str,
267        _max_results: u32,
268        _pagination_token: Option<&str>,
269    ) -> Result<SearchResponse, XApiError> {
270        Err(XApiError::ApiError {
271            status: 0,
272            message: "not implemented".to_string(),
273        })
274    }
275
276    /// Get the authenticated user's bookmarks.
277    async fn get_bookmarks(
278        &self,
279        _user_id: &str,
280        _max_results: u32,
281        _pagination_token: Option<&str>,
282    ) -> Result<SearchResponse, XApiError> {
283        Err(XApiError::ApiError {
284            status: 0,
285            message: "not implemented".to_string(),
286        })
287    }
288
289    /// Bookmark a tweet.
290    async fn bookmark_tweet(&self, _user_id: &str, _tweet_id: &str) -> Result<bool, XApiError> {
291        Err(XApiError::ApiError {
292            status: 0,
293            message: "not implemented".to_string(),
294        })
295    }
296
297    /// Remove a bookmark.
298    async fn unbookmark_tweet(&self, _user_id: &str, _tweet_id: &str) -> Result<bool, XApiError> {
299        Err(XApiError::ApiError {
300            status: 0,
301            message: "not implemented".to_string(),
302        })
303    }
304
305    /// Get multiple users by their IDs.
306    async fn get_users_by_ids(&self, _user_ids: &[&str]) -> Result<UsersResponse, XApiError> {
307        Err(XApiError::ApiError {
308            status: 0,
309            message: "not implemented".to_string(),
310        })
311    }
312
313    /// Get users who liked a specific tweet.
314    async fn get_tweet_liking_users(
315        &self,
316        _tweet_id: &str,
317        _max_results: u32,
318        _pagination_token: Option<&str>,
319    ) -> Result<UsersResponse, XApiError> {
320        Err(XApiError::ApiError {
321            status: 0,
322            message: "not implemented".to_string(),
323        })
324    }
325
326    /// Execute a raw HTTP request against the X API.
327    ///
328    /// The caller is responsible for constructing a valid, pre-validated URL.
329    /// The implementation adds Bearer token authentication automatically.
330    /// Returns the raw HTTP response including status, selected headers, and body text.
331    ///
332    /// `method` must be one of: `GET`, `POST`, `PUT`, `DELETE`.
333    async fn raw_request(
334        &self,
335        _method: &str,
336        _url: &str,
337        _query: Option<&[(String, String)]>,
338        _body: Option<&str>,
339        _headers: Option<&[(String, String)]>,
340    ) -> Result<RawApiResponse, XApiError> {
341        Err(XApiError::ApiError {
342            status: 0,
343            message: "raw_request not implemented".to_string(),
344        })
345    }
346}