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 the cookie transport, or an error if unavailable.
158    ///
159    /// Must only be called after `check_mutation()` succeeds (which already
160    /// guards against `cookie_transport.is_none()`).  Using this helper
161    /// avoids `.unwrap()` scatter across every mutation method.
162    fn transport(&self) -> Result<&CookieTransport, XApiError> {
163        self.cookie_transport
164            .as_ref()
165            .ok_or_else(|| XApiError::ScraperTransportUnavailable {
166                message: "no browser session imported. \
167                          Import cookies via Settings → X API → Import Browser Session."
168                    .to_string(),
169            })
170    }
171
172    /// Return a transport-unavailable error for read methods.
173    fn read_stub(method: &str) -> XApiError {
174        XApiError::ScraperTransportUnavailable {
175            message: format!("{method}: scraper transport not yet implemented"),
176        }
177    }
178
179    /// Return a feature-requires-auth error.
180    fn auth_required(method: &str) -> XApiError {
181        XApiError::FeatureRequiresAuth {
182            message: format!("{method} requires authenticated API access"),
183        }
184    }
185}
186
187#[async_trait::async_trait]
188impl XApiClient for LocalModeXClient {
189    // --- Auth-gated methods ---
190
191    async fn get_me(&self) -> Result<User, XApiError> {
192        if let Some(ref transport) = self.cookie_transport {
193            return transport.fetch_viewer().await;
194        }
195        Err(Self::auth_required("get_me"))
196    }
197
198    async fn get_mentions(
199        &self,
200        _user_id: &str,
201        _since_id: Option<&str>,
202        _pagination_token: Option<&str>,
203    ) -> Result<MentionResponse, XApiError> {
204        // No direct GraphQL endpoint for mentions
205        Err(Self::auth_required("get_mentions"))
206    }
207
208    async fn get_home_timeline(
209        &self,
210        _user_id: &str,
211        max_results: u32,
212        pagination_token: Option<&str>,
213    ) -> Result<SearchResponse, XApiError> {
214        if let Some(ref transport) = self.cookie_transport {
215            return transport
216                .get_home_timeline(max_results, pagination_token)
217                .await;
218        }
219        Err(Self::auth_required("get_home_timeline"))
220    }
221
222    async fn get_bookmarks(
223        &self,
224        _user_id: &str,
225        max_results: u32,
226        pagination_token: Option<&str>,
227    ) -> Result<SearchResponse, XApiError> {
228        if let Some(ref transport) = self.cookie_transport {
229            return transport.get_bookmarks(max_results, pagination_token).await;
230        }
231        Err(Self::auth_required("get_bookmarks"))
232    }
233
234    async fn bookmark_tweet(&self, _user_id: &str, tweet_id: &str) -> Result<bool, XApiError> {
235        self.check_mutation("bookmark_tweet")?;
236        self.transport()?.create_bookmark(tweet_id).await
237    }
238
239    async fn unbookmark_tweet(&self, _user_id: &str, tweet_id: &str) -> Result<bool, XApiError> {
240        self.check_mutation("unbookmark_tweet")?;
241        self.transport()?.delete_bookmark(tweet_id).await
242    }
243
244    // --- Read methods (delegated to cookie transport when available) ---
245
246    async fn search_tweets(
247        &self,
248        query: &str,
249        max_results: u32,
250        _since_id: Option<&str>,
251        pagination_token: Option<&str>,
252    ) -> Result<SearchResponse, XApiError> {
253        if let Some(ref transport) = self.cookie_transport {
254            return transport
255                .search_timeline(query, max_results, pagination_token)
256                .await;
257        }
258        Err(Self::read_stub("search_tweets"))
259    }
260
261    async fn get_tweet(&self, tweet_id: &str) -> Result<Tweet, XApiError> {
262        if let Some(ref transport) = self.cookie_transport {
263            return transport.get_tweet_by_id(tweet_id).await;
264        }
265        Err(Self::read_stub("get_tweet"))
266    }
267
268    async fn get_user_by_username(&self, username: &str) -> Result<User, XApiError> {
269        if let Some(ref transport) = self.cookie_transport {
270            return transport.get_user_by_screen_name(username).await;
271        }
272        Err(Self::read_stub("get_user_by_username"))
273    }
274
275    async fn get_user_tweets(
276        &self,
277        user_id: &str,
278        max_results: u32,
279        pagination_token: Option<&str>,
280    ) -> Result<SearchResponse, XApiError> {
281        if let Some(ref transport) = self.cookie_transport {
282            return transport
283                .get_user_tweets(user_id, max_results, pagination_token)
284                .await;
285        }
286        Err(Self::read_stub("get_user_tweets"))
287    }
288
289    async fn get_user_by_id(&self, user_id: &str) -> Result<User, XApiError> {
290        if let Some(ref transport) = self.cookie_transport {
291            return transport.get_user_by_rest_id(user_id).await;
292        }
293        Err(Self::read_stub("get_user_by_id"))
294    }
295
296    async fn get_followers(
297        &self,
298        user_id: &str,
299        max_results: u32,
300        pagination_token: Option<&str>,
301    ) -> Result<UsersResponse, XApiError> {
302        if let Some(ref transport) = self.cookie_transport {
303            return transport
304                .get_followers(user_id, max_results, pagination_token)
305                .await;
306        }
307        Err(Self::read_stub("get_followers"))
308    }
309
310    async fn get_following(
311        &self,
312        user_id: &str,
313        max_results: u32,
314        pagination_token: Option<&str>,
315    ) -> Result<UsersResponse, XApiError> {
316        if let Some(ref transport) = self.cookie_transport {
317            return transport
318                .get_following(user_id, max_results, pagination_token)
319                .await;
320        }
321        Err(Self::read_stub("get_following"))
322    }
323
324    async fn get_users_by_ids(&self, _user_ids: &[&str]) -> Result<UsersResponse, XApiError> {
325        // No clean GraphQL batch endpoint
326        Err(Self::read_stub("get_users_by_ids"))
327    }
328
329    async fn get_liked_tweets(
330        &self,
331        user_id: &str,
332        max_results: u32,
333        pagination_token: Option<&str>,
334    ) -> Result<SearchResponse, XApiError> {
335        if let Some(ref transport) = self.cookie_transport {
336            return transport
337                .get_liked_tweets(user_id, max_results, pagination_token)
338                .await;
339        }
340        Err(Self::read_stub("get_liked_tweets"))
341    }
342
343    async fn get_tweet_liking_users(
344        &self,
345        _tweet_id: &str,
346        _max_results: u32,
347        _pagination_token: Option<&str>,
348    ) -> Result<UsersResponse, XApiError> {
349        // No clean GraphQL endpoint
350        Err(Self::read_stub("get_tweet_liking_users"))
351    }
352
353    async fn raw_request(
354        &self,
355        _method: &str,
356        _url: &str,
357        _query: Option<&[(String, String)]>,
358        _body: Option<&str>,
359        _headers: Option<&[(String, String)]>,
360    ) -> Result<RawApiResponse, XApiError> {
361        Err(Self::read_stub("raw_request"))
362    }
363
364    // --- Write methods (mutation-gated, delegated to cookie transport) ---
365
366    async fn post_tweet(&self, text: &str) -> Result<PostedTweet, XApiError> {
367        self.check_mutation("post_tweet")?;
368        let transport = self.transport()?;
369        let text = text.to_string();
370        self.with_retry_and_health(|| {
371            let t = text.clone();
372            async move { transport.post_tweet(&t).await }
373        })
374        .await
375    }
376
377    async fn reply_to_tweet(
378        &self,
379        text: &str,
380        in_reply_to_id: &str,
381    ) -> Result<PostedTweet, XApiError> {
382        self.check_mutation("reply_to_tweet")?;
383        let transport = self.transport()?;
384        let text = text.to_string();
385        let reply_id = in_reply_to_id.to_string();
386        self.with_retry_and_health(|| {
387            let t = text.clone();
388            let r = reply_id.clone();
389            async move { transport.reply_to_tweet(&t, &r).await }
390        })
391        .await
392    }
393
394    async fn post_tweet_with_media(
395        &self,
396        text: &str,
397        _media_ids: &[String],
398    ) -> Result<PostedTweet, XApiError> {
399        self.check_mutation("post_tweet_with_media")?;
400        // Media upload not yet supported via cookie transport — post text only.
401        let transport = self.transport()?;
402        let text = text.to_string();
403        self.with_retry_and_health(|| {
404            let t = text.clone();
405            async move { transport.post_tweet(&t).await }
406        })
407        .await
408    }
409
410    async fn reply_to_tweet_with_media(
411        &self,
412        text: &str,
413        in_reply_to_id: &str,
414        _media_ids: &[String],
415    ) -> Result<PostedTweet, XApiError> {
416        self.check_mutation("reply_to_tweet_with_media")?;
417        // Media upload not yet supported via cookie transport — post text only.
418        let transport = self.transport()?;
419        let text = text.to_string();
420        let reply_id = in_reply_to_id.to_string();
421        self.with_retry_and_health(|| {
422            let t = text.clone();
423            let r = reply_id.clone();
424            async move { transport.reply_to_tweet(&t, &r).await }
425        })
426        .await
427    }
428
429    async fn quote_tweet(
430        &self,
431        _text: &str,
432        _quoted_tweet_id: &str,
433    ) -> Result<PostedTweet, XApiError> {
434        self.check_mutation("quote_tweet")?;
435        Err(XApiError::ScraperTransportUnavailable {
436            message: "quote_tweet not yet supported via cookie transport".to_string(),
437        })
438    }
439
440    async fn like_tweet(&self, _user_id: &str, tweet_id: &str) -> Result<bool, XApiError> {
441        self.check_mutation("like_tweet")?;
442        self.transport()?.favorite_tweet(tweet_id).await
443    }
444
445    async fn unlike_tweet(&self, _user_id: &str, tweet_id: &str) -> Result<bool, XApiError> {
446        self.check_mutation("unlike_tweet")?;
447        self.transport()?.unfavorite_tweet(tweet_id).await
448    }
449
450    async fn follow_user(&self, _user_id: &str, target_user_id: &str) -> Result<bool, XApiError> {
451        self.check_mutation("follow_user")?;
452        self.transport()?.follow_user(target_user_id).await
453    }
454
455    async fn unfollow_user(&self, _user_id: &str, target_user_id: &str) -> Result<bool, XApiError> {
456        self.check_mutation("unfollow_user")?;
457        self.transport()?.unfollow_user(target_user_id).await
458    }
459
460    async fn retweet(&self, _user_id: &str, tweet_id: &str) -> Result<bool, XApiError> {
461        self.check_mutation("retweet")?;
462        self.transport()?.create_retweet(tweet_id).await
463    }
464
465    async fn unretweet(&self, _user_id: &str, tweet_id: &str) -> Result<bool, XApiError> {
466        self.check_mutation("unretweet")?;
467        self.transport()?.delete_retweet(tweet_id).await
468    }
469
470    async fn delete_tweet(&self, tweet_id: &str) -> Result<bool, XApiError> {
471        self.check_mutation("delete_tweet")?;
472        self.transport()?.delete_tweet(tweet_id).await
473    }
474
475    // --- Media (always unavailable in scraper mode) ---
476
477    async fn upload_media(
478        &self,
479        _data: &[u8],
480        _media_type: MediaType,
481    ) -> Result<MediaId, XApiError> {
482        Err(XApiError::MediaUploadError {
483            message: "media upload unavailable in scraper mode".to_string(),
484        })
485    }
486}
487
488#[cfg(test)]
489mod tests;