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//! write methods use cookie-based authentication to post 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::types::{
16    MediaId, MediaType, MentionResponse, PostedTweet, RawApiResponse, SearchResponse, Tweet, User,
17    UsersResponse,
18};
19use crate::x_api::XApiClient;
20
21use cookie_transport::CookieTransport;
22use session::ScraperSession;
23
24/// X API client for local/scraper mode — no OAuth credentials required.
25///
26/// When a valid `scraper_session.json` exists in the data directory,
27/// write operations are dispatched to the cookie-based transport.
28/// Otherwise, they return `ScraperTransportUnavailable`.
29pub struct LocalModeXClient {
30    allow_mutations: bool,
31    cookie_transport: Option<CookieTransport>,
32}
33
34impl LocalModeXClient {
35    /// Create a new local-mode client.
36    ///
37    /// `allow_mutations` controls whether write operations are attempted
38    /// (when `true`) or immediately rejected (when `false`).
39    pub fn new(allow_mutations: bool) -> Self {
40        Self {
41            allow_mutations,
42            cookie_transport: None,
43        }
44    }
45
46    /// Create a local-mode client with cookie-auth from a session file.
47    ///
48    /// If the session file exists and is valid, write operations will use
49    /// the cookie transport. Otherwise, falls back to stub behavior.
50    ///
51    /// Auto-detects the current CreateTweet GraphQL query ID from X's
52    /// web client JS bundles at startup.
53    pub async fn with_session(allow_mutations: bool, data_dir: &Path) -> Self {
54        let session_path = data_dir.join("scraper_session.json");
55        let session = ScraperSession::load(&session_path).ok().flatten();
56
57        let cookie_transport = if let Some(session) = session {
58            let resolved = cookie_transport::resolve_transport().await;
59            tracing::info!("Cookie-auth transport loaded from scraper_session.json");
60            Some(CookieTransport::with_query_id(
61                session,
62                resolved.query_id,
63                resolved.transaction,
64            ))
65        } else {
66            None
67        };
68
69        Self {
70            allow_mutations,
71            cookie_transport,
72        }
73    }
74
75    /// Path to the session file in a given data directory.
76    pub fn session_path(data_dir: &Path) -> PathBuf {
77        data_dir.join("scraper_session.json")
78    }
79
80    /// Check mutation gate and delegate to cookie transport if available.
81    fn check_mutation(&self, method: &str) -> Result<(), XApiError> {
82        if !self.allow_mutations {
83            return Err(XApiError::ScraperMutationBlocked {
84                message: method.to_string(),
85            });
86        }
87        if self.cookie_transport.is_none() {
88            return Err(XApiError::ScraperTransportUnavailable {
89                message: format!(
90                    "{method}: no browser session imported. \
91                     Import cookies via Settings → X API → Import Browser Session."
92                ),
93            });
94        }
95        Ok(())
96    }
97
98    /// Return a transport-unavailable error for read methods.
99    fn read_stub(method: &str) -> XApiError {
100        XApiError::ScraperTransportUnavailable {
101            message: format!("{method}: scraper transport not yet implemented"),
102        }
103    }
104
105    /// Return a feature-requires-auth error.
106    fn auth_required(method: &str) -> XApiError {
107        XApiError::FeatureRequiresAuth {
108            message: format!("{method} requires authenticated API access"),
109        }
110    }
111}
112
113#[async_trait::async_trait]
114impl XApiClient for LocalModeXClient {
115    // --- Auth-gated methods ---
116
117    async fn get_me(&self) -> Result<User, XApiError> {
118        if let Some(ref transport) = self.cookie_transport {
119            return transport.fetch_viewer().await;
120        }
121        Err(Self::auth_required("get_me"))
122    }
123
124    async fn get_mentions(
125        &self,
126        _user_id: &str,
127        _since_id: Option<&str>,
128        _pagination_token: Option<&str>,
129    ) -> Result<MentionResponse, XApiError> {
130        Err(Self::auth_required("get_mentions"))
131    }
132
133    async fn get_home_timeline(
134        &self,
135        _user_id: &str,
136        _max_results: u32,
137        _pagination_token: Option<&str>,
138    ) -> Result<SearchResponse, XApiError> {
139        Err(Self::auth_required("get_home_timeline"))
140    }
141
142    async fn get_bookmarks(
143        &self,
144        _user_id: &str,
145        _max_results: u32,
146        _pagination_token: Option<&str>,
147    ) -> Result<SearchResponse, XApiError> {
148        Err(Self::auth_required("get_bookmarks"))
149    }
150
151    async fn bookmark_tweet(&self, _user_id: &str, _tweet_id: &str) -> Result<bool, XApiError> {
152        Err(Self::auth_required("bookmark_tweet"))
153    }
154
155    async fn unbookmark_tweet(&self, _user_id: &str, _tweet_id: &str) -> Result<bool, XApiError> {
156        Err(Self::auth_required("unbookmark_tweet"))
157    }
158
159    // --- Read methods (transport stubs) ---
160
161    async fn search_tweets(
162        &self,
163        _query: &str,
164        _max_results: u32,
165        _since_id: Option<&str>,
166        _pagination_token: Option<&str>,
167    ) -> Result<SearchResponse, XApiError> {
168        Err(Self::read_stub("search_tweets"))
169    }
170
171    async fn get_tweet(&self, _tweet_id: &str) -> Result<Tweet, XApiError> {
172        Err(Self::read_stub("get_tweet"))
173    }
174
175    async fn get_user_by_username(&self, _username: &str) -> Result<User, XApiError> {
176        Err(Self::read_stub("get_user_by_username"))
177    }
178
179    async fn get_user_tweets(
180        &self,
181        _user_id: &str,
182        _max_results: u32,
183        _pagination_token: Option<&str>,
184    ) -> Result<SearchResponse, XApiError> {
185        Err(Self::read_stub("get_user_tweets"))
186    }
187
188    async fn get_user_by_id(&self, _user_id: &str) -> Result<User, XApiError> {
189        Err(Self::read_stub("get_user_by_id"))
190    }
191
192    async fn get_followers(
193        &self,
194        _user_id: &str,
195        _max_results: u32,
196        _pagination_token: Option<&str>,
197    ) -> Result<UsersResponse, XApiError> {
198        Err(Self::read_stub("get_followers"))
199    }
200
201    async fn get_following(
202        &self,
203        _user_id: &str,
204        _max_results: u32,
205        _pagination_token: Option<&str>,
206    ) -> Result<UsersResponse, XApiError> {
207        Err(Self::read_stub("get_following"))
208    }
209
210    async fn get_users_by_ids(&self, _user_ids: &[&str]) -> Result<UsersResponse, XApiError> {
211        Err(Self::read_stub("get_users_by_ids"))
212    }
213
214    async fn get_liked_tweets(
215        &self,
216        _user_id: &str,
217        _max_results: u32,
218        _pagination_token: Option<&str>,
219    ) -> Result<SearchResponse, XApiError> {
220        Err(Self::read_stub("get_liked_tweets"))
221    }
222
223    async fn get_tweet_liking_users(
224        &self,
225        _tweet_id: &str,
226        _max_results: u32,
227        _pagination_token: Option<&str>,
228    ) -> Result<UsersResponse, XApiError> {
229        Err(Self::read_stub("get_tweet_liking_users"))
230    }
231
232    async fn raw_request(
233        &self,
234        _method: &str,
235        _url: &str,
236        _query: Option<&[(String, String)]>,
237        _body: Option<&str>,
238        _headers: Option<&[(String, String)]>,
239    ) -> Result<RawApiResponse, XApiError> {
240        Err(Self::read_stub("raw_request"))
241    }
242
243    // --- Write methods (mutation-gated) ---
244
245    async fn post_tweet(&self, text: &str) -> Result<PostedTweet, XApiError> {
246        self.check_mutation("post_tweet")?;
247        self.cookie_transport
248            .as_ref()
249            .unwrap()
250            .post_tweet(text)
251            .await
252    }
253
254    async fn reply_to_tweet(
255        &self,
256        text: &str,
257        in_reply_to_id: &str,
258    ) -> Result<PostedTweet, XApiError> {
259        self.check_mutation("reply_to_tweet")?;
260        self.cookie_transport
261            .as_ref()
262            .unwrap()
263            .reply_to_tweet(text, in_reply_to_id)
264            .await
265    }
266
267    async fn post_tweet_with_media(
268        &self,
269        text: &str,
270        _media_ids: &[String],
271    ) -> Result<PostedTweet, XApiError> {
272        self.check_mutation("post_tweet_with_media")?;
273        // Media upload not yet supported via cookie transport — post text only.
274        self.cookie_transport
275            .as_ref()
276            .unwrap()
277            .post_tweet(text)
278            .await
279    }
280
281    async fn reply_to_tweet_with_media(
282        &self,
283        text: &str,
284        in_reply_to_id: &str,
285        _media_ids: &[String],
286    ) -> Result<PostedTweet, XApiError> {
287        self.check_mutation("reply_to_tweet_with_media")?;
288        // Media upload not yet supported via cookie transport — post text only.
289        self.cookie_transport
290            .as_ref()
291            .unwrap()
292            .reply_to_tweet(text, in_reply_to_id)
293            .await
294    }
295
296    async fn quote_tweet(
297        &self,
298        _text: &str,
299        _quoted_tweet_id: &str,
300    ) -> Result<PostedTweet, XApiError> {
301        self.check_mutation("quote_tweet")?;
302        Err(XApiError::ScraperTransportUnavailable {
303            message: "quote_tweet not yet supported via cookie transport".to_string(),
304        })
305    }
306
307    async fn like_tweet(&self, _user_id: &str, _tweet_id: &str) -> Result<bool, XApiError> {
308        self.check_mutation("like_tweet")?;
309        Err(XApiError::ScraperTransportUnavailable {
310            message: "like_tweet not yet supported via cookie transport".to_string(),
311        })
312    }
313
314    async fn unlike_tweet(&self, _user_id: &str, _tweet_id: &str) -> Result<bool, XApiError> {
315        self.check_mutation("unlike_tweet")?;
316        Err(XApiError::ScraperTransportUnavailable {
317            message: "unlike_tweet not yet supported via cookie transport".to_string(),
318        })
319    }
320
321    async fn follow_user(&self, _user_id: &str, _target_user_id: &str) -> Result<bool, XApiError> {
322        self.check_mutation("follow_user")?;
323        Err(XApiError::ScraperTransportUnavailable {
324            message: "follow_user not yet supported via cookie transport".to_string(),
325        })
326    }
327
328    async fn unfollow_user(
329        &self,
330        _user_id: &str,
331        _target_user_id: &str,
332    ) -> Result<bool, XApiError> {
333        self.check_mutation("unfollow_user")?;
334        Err(XApiError::ScraperTransportUnavailable {
335            message: "unfollow_user not yet supported via cookie transport".to_string(),
336        })
337    }
338
339    async fn retweet(&self, _user_id: &str, _tweet_id: &str) -> Result<bool, XApiError> {
340        self.check_mutation("retweet")?;
341        Err(XApiError::ScraperTransportUnavailable {
342            message: "retweet not yet supported via cookie transport".to_string(),
343        })
344    }
345
346    async fn unretweet(&self, _user_id: &str, _tweet_id: &str) -> Result<bool, XApiError> {
347        self.check_mutation("unretweet")?;
348        Err(XApiError::ScraperTransportUnavailable {
349            message: "unretweet not yet supported via cookie transport".to_string(),
350        })
351    }
352
353    async fn delete_tweet(&self, _tweet_id: &str) -> Result<bool, XApiError> {
354        self.check_mutation("delete_tweet")?;
355        Err(XApiError::ScraperTransportUnavailable {
356            message: "delete_tweet not yet supported via cookie transport".to_string(),
357        })
358    }
359
360    // --- Media (always unavailable in scraper mode) ---
361
362    async fn upload_media(
363        &self,
364        _data: &[u8],
365        _media_type: MediaType,
366    ) -> Result<MediaId, XApiError> {
367        Err(XApiError::MediaUploadError {
368            message: "media upload unavailable in scraper mode".to_string(),
369        })
370    }
371}
372
373#[cfg(test)]
374mod tests;