tuitbot_core/x_api/local_mode/
mod.rs1pub 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
24pub struct LocalModeXClient {
30 allow_mutations: bool,
31 cookie_transport: Option<CookieTransport>,
32}
33
34impl LocalModeXClient {
35 pub fn new(allow_mutations: bool) -> Self {
40 Self {
41 allow_mutations,
42 cookie_transport: None,
43 }
44 }
45
46 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 pub fn session_path(data_dir: &Path) -> PathBuf {
77 data_dir.join("scraper_session.json")
78 }
79
80 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 fn read_stub(method: &str) -> XApiError {
100 XApiError::ScraperTransportUnavailable {
101 message: format!("{method}: scraper transport not yet implemented"),
102 }
103 }
104
105 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 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 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 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 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 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 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;