Skip to main content

tuitbot_core/x_api/local_mode/
mod.rs

1//! Local-mode X API client for scraper backend.
2//!
3//! Implements `XApiClient` without requiring OAuth credentials.
4//! When a browser session file (`scraper_session.json`) is present,
5//! read and write methods use cookie-based authentication via X's
6//! internal GraphQL API. Without a session, write methods return
7//! `ScraperTransportUnavailable`.
8
9pub mod cookie_transport;
10pub mod session;
11
12use std::path::{Path, PathBuf};
13
14use crate::error::XApiError;
15use crate::x_api::retry::{retry_with_backoff, RetryConfig};
16use crate::x_api::scraper_health::{new_scraper_health, ScraperHealth};
17use crate::x_api::types::{
18    MediaId, MediaType, MentionResponse, PostedTweet, RawApiResponse, SearchResponse, Tweet, User,
19    UsersResponse,
20};
21use crate::x_api::XApiClient;
22
23use cookie_transport::CookieTransport;
24use session::ScraperSession;
25
26/// Default retry policy for scraper operations.
27const SCRAPER_RETRY: RetryConfig = RetryConfig {
28    max_attempts: 3,
29    base_delay: std::time::Duration::from_millis(500),
30    max_delay: std::time::Duration::from_secs(8),
31};
32
33/// X API client for local/scraper mode — no OAuth credentials required.
34///
35/// When a valid `scraper_session.json` exists in the data directory,
36/// operations are dispatched to the cookie-based transport.
37/// Otherwise, they return `ScraperTransportUnavailable`.
38///
39/// All transport calls are wrapped with `retry_with_backoff` so transient
40/// network errors and 5xx responses are retried automatically.  Health
41/// is tracked in `health` and exposed via [`LocalModeXClient::health`].
42pub struct LocalModeXClient {
43    allow_mutations: bool,
44    cookie_transport: Option<CookieTransport>,
45    /// Shared health tracker — updated after every transport call.
46    health: ScraperHealth,
47}
48
49impl LocalModeXClient {
50    /// Create a new local-mode client (no session — stub mode).
51    ///
52    /// `allow_mutations` controls whether write operations are attempted
53    /// (when `true`) or immediately rejected (when `false`).
54    pub fn new(allow_mutations: bool) -> Self {
55        Self {
56            allow_mutations,
57            cookie_transport: None,
58            health: new_scraper_health(),
59        }
60    }
61
62    /// Create a local-mode client with cookie-auth from a session file.
63    ///
64    /// If the session file exists and is valid, operations will use
65    /// the cookie transport. Otherwise, falls back to stub behavior.
66    ///
67    /// Auto-detects GraphQL query IDs from X's web client JS bundles at startup.
68    pub async fn with_session(allow_mutations: bool, data_dir: &Path) -> Self {
69        Self::with_session_and_health(allow_mutations, data_dir, new_scraper_health()).await
70    }
71
72    /// Create a local-mode client with cookie-auth and a **shared** health handle.
73    ///
74    /// Use this instead of [`with_session`] when you want multiple ephemeral
75    /// clients (e.g. one per HTTP request) to update the same health tracker
76    /// held in `AppState`. Enables the `/health` endpoint to reflect real
77    /// scraper health across the lifetime of the server process.
78    pub async fn with_session_and_health(
79        allow_mutations: bool,
80        data_dir: &Path,
81        health: ScraperHealth,
82    ) -> Self {
83        let session_path = data_dir.join("scraper_session.json");
84        let session = ScraperSession::load(&session_path).ok().flatten();
85
86        let cookie_transport = if let Some(session) = session {
87            let resolved = cookie_transport::resolve_transport().await;
88            tracing::info!("Cookie-auth transport loaded from scraper_session.json");
89            Some(CookieTransport::with_resolved_transport(
90                session,
91                resolved.query_ids,
92                resolved.transaction,
93            ))
94        } else {
95            None
96        };
97
98        Self {
99            allow_mutations,
100            cookie_transport,
101            health,
102        }
103    }
104
105    /// Return a clone of the shared health handle.
106    ///
107    /// Callers (e.g. the health endpoint) can snapshot the current state
108    /// without coupling to the client implementation.
109    pub fn health(&self) -> ScraperHealth {
110        self.health.clone()
111    }
112
113    /// Wrap a transport call with retry logic and health tracking.
114    ///
115    /// On success: resets consecutive failure counter.
116    /// On final failure: increments counter and records last error.
117    async fn with_retry_and_health<F, Fut, T>(&self, op: F) -> Result<T, XApiError>
118    where
119        F: FnMut() -> Fut,
120        Fut: std::future::Future<Output = Result<T, XApiError>>,
121    {
122        match retry_with_backoff(SCRAPER_RETRY, op).await {
123            Ok(v) => {
124                self.health.lock().await.record_success();
125                Ok(v)
126            }
127            Err(e) => {
128                self.health.lock().await.record_failure(&e.to_string());
129                Err(e)
130            }
131        }
132    }
133
134    /// Path to the session file in a given data directory.
135    pub fn session_path(data_dir: &Path) -> PathBuf {
136        data_dir.join("scraper_session.json")
137    }
138
139    /// Check mutation gate and delegate to cookie transport if available.
140    fn check_mutation(&self, method: &str) -> Result<(), XApiError> {
141        if !self.allow_mutations {
142            return Err(XApiError::ScraperMutationBlocked {
143                message: method.to_string(),
144            });
145        }
146        if self.cookie_transport.is_none() {
147            return Err(XApiError::ScraperTransportUnavailable {
148                message: format!(
149                    "{method}: no browser session imported. \
150                     Import cookies via Settings → X API → Import Browser Session."
151                ),
152            });
153        }
154        Ok(())
155    }
156
157    /// Return a transport-unavailable error for read methods.
158    fn read_stub(method: &str) -> XApiError {
159        XApiError::ScraperTransportUnavailable {
160            message: format!("{method}: scraper transport not yet implemented"),
161        }
162    }
163
164    /// Return a feature-requires-auth error.
165    fn auth_required(method: &str) -> XApiError {
166        XApiError::FeatureRequiresAuth {
167            message: format!("{method} requires authenticated API access"),
168        }
169    }
170}
171
172#[async_trait::async_trait]
173impl XApiClient for LocalModeXClient {
174    // --- Auth-gated methods ---
175
176    async fn get_me(&self) -> Result<User, XApiError> {
177        if let Some(ref transport) = self.cookie_transport {
178            return transport.fetch_viewer().await;
179        }
180        Err(Self::auth_required("get_me"))
181    }
182
183    async fn get_mentions(
184        &self,
185        _user_id: &str,
186        _since_id: Option<&str>,
187        _pagination_token: Option<&str>,
188    ) -> Result<MentionResponse, XApiError> {
189        // No direct GraphQL endpoint for mentions
190        Err(Self::auth_required("get_mentions"))
191    }
192
193    async fn get_home_timeline(
194        &self,
195        _user_id: &str,
196        max_results: u32,
197        pagination_token: Option<&str>,
198    ) -> Result<SearchResponse, XApiError> {
199        if let Some(ref transport) = self.cookie_transport {
200            return transport
201                .get_home_timeline(max_results, pagination_token)
202                .await;
203        }
204        Err(Self::auth_required("get_home_timeline"))
205    }
206
207    async fn get_bookmarks(
208        &self,
209        _user_id: &str,
210        max_results: u32,
211        pagination_token: Option<&str>,
212    ) -> Result<SearchResponse, XApiError> {
213        if let Some(ref transport) = self.cookie_transport {
214            return transport.get_bookmarks(max_results, pagination_token).await;
215        }
216        Err(Self::auth_required("get_bookmarks"))
217    }
218
219    async fn bookmark_tweet(&self, _user_id: &str, tweet_id: &str) -> Result<bool, XApiError> {
220        self.check_mutation("bookmark_tweet")?;
221        self.cookie_transport
222            .as_ref()
223            .unwrap()
224            .create_bookmark(tweet_id)
225            .await
226    }
227
228    async fn unbookmark_tweet(&self, _user_id: &str, tweet_id: &str) -> Result<bool, XApiError> {
229        self.check_mutation("unbookmark_tweet")?;
230        self.cookie_transport
231            .as_ref()
232            .unwrap()
233            .delete_bookmark(tweet_id)
234            .await
235    }
236
237    // --- Read methods (delegated to cookie transport when available) ---
238
239    async fn search_tweets(
240        &self,
241        query: &str,
242        max_results: u32,
243        _since_id: Option<&str>,
244        pagination_token: Option<&str>,
245    ) -> Result<SearchResponse, XApiError> {
246        if let Some(ref transport) = self.cookie_transport {
247            return transport
248                .search_timeline(query, max_results, pagination_token)
249                .await;
250        }
251        Err(Self::read_stub("search_tweets"))
252    }
253
254    async fn get_tweet(&self, tweet_id: &str) -> Result<Tweet, XApiError> {
255        if let Some(ref transport) = self.cookie_transport {
256            return transport.get_tweet_by_id(tweet_id).await;
257        }
258        Err(Self::read_stub("get_tweet"))
259    }
260
261    async fn get_user_by_username(&self, username: &str) -> Result<User, XApiError> {
262        if let Some(ref transport) = self.cookie_transport {
263            return transport.get_user_by_screen_name(username).await;
264        }
265        Err(Self::read_stub("get_user_by_username"))
266    }
267
268    async fn get_user_tweets(
269        &self,
270        user_id: &str,
271        max_results: u32,
272        pagination_token: Option<&str>,
273    ) -> Result<SearchResponse, XApiError> {
274        if let Some(ref transport) = self.cookie_transport {
275            return transport
276                .get_user_tweets(user_id, max_results, pagination_token)
277                .await;
278        }
279        Err(Self::read_stub("get_user_tweets"))
280    }
281
282    async fn get_user_by_id(&self, user_id: &str) -> Result<User, XApiError> {
283        if let Some(ref transport) = self.cookie_transport {
284            return transport.get_user_by_rest_id(user_id).await;
285        }
286        Err(Self::read_stub("get_user_by_id"))
287    }
288
289    async fn get_followers(
290        &self,
291        user_id: &str,
292        max_results: u32,
293        pagination_token: Option<&str>,
294    ) -> Result<UsersResponse, XApiError> {
295        if let Some(ref transport) = self.cookie_transport {
296            return transport
297                .get_followers(user_id, max_results, pagination_token)
298                .await;
299        }
300        Err(Self::read_stub("get_followers"))
301    }
302
303    async fn get_following(
304        &self,
305        user_id: &str,
306        max_results: u32,
307        pagination_token: Option<&str>,
308    ) -> Result<UsersResponse, XApiError> {
309        if let Some(ref transport) = self.cookie_transport {
310            return transport
311                .get_following(user_id, max_results, pagination_token)
312                .await;
313        }
314        Err(Self::read_stub("get_following"))
315    }
316
317    async fn get_users_by_ids(&self, _user_ids: &[&str]) -> Result<UsersResponse, XApiError> {
318        // No clean GraphQL batch endpoint
319        Err(Self::read_stub("get_users_by_ids"))
320    }
321
322    async fn get_liked_tweets(
323        &self,
324        user_id: &str,
325        max_results: u32,
326        pagination_token: Option<&str>,
327    ) -> Result<SearchResponse, XApiError> {
328        if let Some(ref transport) = self.cookie_transport {
329            return transport
330                .get_liked_tweets(user_id, max_results, pagination_token)
331                .await;
332        }
333        Err(Self::read_stub("get_liked_tweets"))
334    }
335
336    async fn get_tweet_liking_users(
337        &self,
338        _tweet_id: &str,
339        _max_results: u32,
340        _pagination_token: Option<&str>,
341    ) -> Result<UsersResponse, XApiError> {
342        // No clean GraphQL endpoint
343        Err(Self::read_stub("get_tweet_liking_users"))
344    }
345
346    async fn raw_request(
347        &self,
348        _method: &str,
349        _url: &str,
350        _query: Option<&[(String, String)]>,
351        _body: Option<&str>,
352        _headers: Option<&[(String, String)]>,
353    ) -> Result<RawApiResponse, XApiError> {
354        Err(Self::read_stub("raw_request"))
355    }
356
357    // --- Write methods (mutation-gated, delegated to cookie transport) ---
358
359    async fn post_tweet(&self, text: &str) -> Result<PostedTweet, XApiError> {
360        self.check_mutation("post_tweet")?;
361        let transport = self.cookie_transport.as_ref().unwrap();
362        let text = text.to_string();
363        self.with_retry_and_health(|| {
364            let t = text.clone();
365            async move { transport.post_tweet(&t).await }
366        })
367        .await
368    }
369
370    async fn reply_to_tweet(
371        &self,
372        text: &str,
373        in_reply_to_id: &str,
374    ) -> Result<PostedTweet, XApiError> {
375        self.check_mutation("reply_to_tweet")?;
376        let transport = self.cookie_transport.as_ref().unwrap();
377        let text = text.to_string();
378        let reply_id = in_reply_to_id.to_string();
379        self.with_retry_and_health(|| {
380            let t = text.clone();
381            let r = reply_id.clone();
382            async move { transport.reply_to_tweet(&t, &r).await }
383        })
384        .await
385    }
386
387    async fn post_tweet_with_media(
388        &self,
389        text: &str,
390        _media_ids: &[String],
391    ) -> Result<PostedTweet, XApiError> {
392        self.check_mutation("post_tweet_with_media")?;
393        // Media upload not yet supported via cookie transport — post text only.
394        let transport = self.cookie_transport.as_ref().unwrap();
395        let text = text.to_string();
396        self.with_retry_and_health(|| {
397            let t = text.clone();
398            async move { transport.post_tweet(&t).await }
399        })
400        .await
401    }
402
403    async fn reply_to_tweet_with_media(
404        &self,
405        text: &str,
406        in_reply_to_id: &str,
407        _media_ids: &[String],
408    ) -> Result<PostedTweet, XApiError> {
409        self.check_mutation("reply_to_tweet_with_media")?;
410        // Media upload not yet supported via cookie transport — post text only.
411        let transport = self.cookie_transport.as_ref().unwrap();
412        let text = text.to_string();
413        let reply_id = in_reply_to_id.to_string();
414        self.with_retry_and_health(|| {
415            let t = text.clone();
416            let r = reply_id.clone();
417            async move { transport.reply_to_tweet(&t, &r).await }
418        })
419        .await
420    }
421
422    async fn quote_tweet(
423        &self,
424        _text: &str,
425        _quoted_tweet_id: &str,
426    ) -> Result<PostedTweet, XApiError> {
427        self.check_mutation("quote_tweet")?;
428        Err(XApiError::ScraperTransportUnavailable {
429            message: "quote_tweet not yet supported via cookie transport".to_string(),
430        })
431    }
432
433    async fn like_tweet(&self, _user_id: &str, tweet_id: &str) -> Result<bool, XApiError> {
434        self.check_mutation("like_tweet")?;
435        self.cookie_transport
436            .as_ref()
437            .unwrap()
438            .favorite_tweet(tweet_id)
439            .await
440    }
441
442    async fn unlike_tweet(&self, _user_id: &str, tweet_id: &str) -> Result<bool, XApiError> {
443        self.check_mutation("unlike_tweet")?;
444        self.cookie_transport
445            .as_ref()
446            .unwrap()
447            .unfavorite_tweet(tweet_id)
448            .await
449    }
450
451    async fn follow_user(&self, _user_id: &str, target_user_id: &str) -> Result<bool, XApiError> {
452        self.check_mutation("follow_user")?;
453        self.cookie_transport
454            .as_ref()
455            .unwrap()
456            .follow_user(target_user_id)
457            .await
458    }
459
460    async fn unfollow_user(&self, _user_id: &str, target_user_id: &str) -> Result<bool, XApiError> {
461        self.check_mutation("unfollow_user")?;
462        self.cookie_transport
463            .as_ref()
464            .unwrap()
465            .unfollow_user(target_user_id)
466            .await
467    }
468
469    async fn retweet(&self, _user_id: &str, tweet_id: &str) -> Result<bool, XApiError> {
470        self.check_mutation("retweet")?;
471        self.cookie_transport
472            .as_ref()
473            .unwrap()
474            .create_retweet(tweet_id)
475            .await
476    }
477
478    async fn unretweet(&self, _user_id: &str, tweet_id: &str) -> Result<bool, XApiError> {
479        self.check_mutation("unretweet")?;
480        self.cookie_transport
481            .as_ref()
482            .unwrap()
483            .delete_retweet(tweet_id)
484            .await
485    }
486
487    async fn delete_tweet(&self, tweet_id: &str) -> Result<bool, XApiError> {
488        self.check_mutation("delete_tweet")?;
489        self.cookie_transport
490            .as_ref()
491            .unwrap()
492            .delete_tweet(tweet_id)
493            .await
494    }
495
496    // --- Media (always unavailable in scraper mode) ---
497
498    async fn upload_media(
499        &self,
500        _data: &[u8],
501        _media_type: MediaType,
502    ) -> Result<MediaId, XApiError> {
503        Err(XApiError::MediaUploadError {
504            message: "media upload unavailable in scraper mode".to_string(),
505        })
506    }
507}
508
509#[cfg(test)]
510mod tests;