1pub mod auth;
8pub mod client;
9pub mod local_mode;
10pub mod media;
11pub mod null_client;
12pub mod retry;
13pub mod scopes;
14pub mod scraper_health;
15pub mod tier;
16pub mod types;
17
18pub use client::XApiHttpClient;
19pub use local_mode::session::ScraperSession;
20pub use local_mode::LocalModeXClient;
21pub use null_client::NullXApiClient;
22pub use scraper_health::{new_scraper_health, ScraperHealth, ScraperHealthSnapshot, ScraperState};
23pub use types::*;
24
25use std::path::Path;
26use std::sync::Arc;
27
28use crate::config::XApiConfig;
29use crate::error::XApiError;
30
31pub async fn create_local_client(config: &XApiConfig) -> Option<Arc<dyn XApiClient>> {
39 create_local_client_with_data_dir(config, None).await
40}
41
42pub async fn create_local_client_with_data_dir(
44 config: &XApiConfig,
45 data_dir: Option<&Path>,
46) -> Option<Arc<dyn XApiClient>> {
47 if config.provider_backend == "scraper" {
48 let client = match data_dir {
49 Some(dir) => LocalModeXClient::with_session(config.scraper_allow_mutations, dir).await,
50 None => LocalModeXClient::new(config.scraper_allow_mutations),
51 };
52 Some(Arc::new(client))
53 } else {
54 None
55 }
56}
57
58#[async_trait::async_trait]
63pub trait XApiClient: Send + Sync {
64 async fn search_tweets(
69 &self,
70 query: &str,
71 max_results: u32,
72 since_id: Option<&str>,
73 pagination_token: Option<&str>,
74 ) -> Result<SearchResponse, XApiError>;
75
76 async fn get_mentions(
80 &self,
81 user_id: &str,
82 since_id: Option<&str>,
83 pagination_token: Option<&str>,
84 ) -> Result<MentionResponse, XApiError>;
85
86 async fn post_tweet(&self, text: &str) -> Result<PostedTweet, XApiError>;
88
89 async fn reply_to_tweet(
91 &self,
92 text: &str,
93 in_reply_to_id: &str,
94 ) -> Result<PostedTweet, XApiError>;
95
96 async fn get_tweet(&self, tweet_id: &str) -> Result<Tweet, XApiError>;
98
99 async fn get_me(&self) -> Result<User, XApiError>;
101
102 async fn get_user_tweets(
104 &self,
105 user_id: &str,
106 max_results: u32,
107 pagination_token: Option<&str>,
108 ) -> Result<SearchResponse, XApiError>;
109
110 async fn get_user_by_username(&self, username: &str) -> Result<User, XApiError>;
112
113 async fn upload_media(
117 &self,
118 _data: &[u8],
119 _media_type: MediaType,
120 ) -> Result<MediaId, XApiError> {
121 Err(XApiError::MediaUploadError {
122 message: "upload_media not implemented".to_string(),
123 })
124 }
125
126 async fn post_tweet_with_media(
130 &self,
131 text: &str,
132 _media_ids: &[String],
133 ) -> Result<PostedTweet, XApiError> {
134 self.post_tweet(text).await
135 }
136
137 async fn reply_to_tweet_with_media(
141 &self,
142 text: &str,
143 in_reply_to_id: &str,
144 _media_ids: &[String],
145 ) -> Result<PostedTweet, XApiError> {
146 self.reply_to_tweet(text, in_reply_to_id).await
147 }
148
149 async fn quote_tweet(
151 &self,
152 _text: &str,
153 _quoted_tweet_id: &str,
154 ) -> Result<PostedTweet, XApiError> {
155 Err(XApiError::ApiError {
156 status: 0,
157 message: "not implemented".to_string(),
158 })
159 }
160
161 async fn like_tweet(&self, _user_id: &str, _tweet_id: &str) -> Result<bool, XApiError> {
163 Err(XApiError::ApiError {
164 status: 0,
165 message: "not implemented".to_string(),
166 })
167 }
168
169 async fn follow_user(&self, _user_id: &str, _target_user_id: &str) -> Result<bool, XApiError> {
171 Err(XApiError::ApiError {
172 status: 0,
173 message: "not implemented".to_string(),
174 })
175 }
176
177 async fn unfollow_user(
179 &self,
180 _user_id: &str,
181 _target_user_id: &str,
182 ) -> Result<bool, XApiError> {
183 Err(XApiError::ApiError {
184 status: 0,
185 message: "not implemented".to_string(),
186 })
187 }
188
189 async fn retweet(&self, _user_id: &str, _tweet_id: &str) -> Result<bool, XApiError> {
191 Err(XApiError::ApiError {
192 status: 0,
193 message: "not implemented".to_string(),
194 })
195 }
196
197 async fn unretweet(&self, _user_id: &str, _tweet_id: &str) -> Result<bool, XApiError> {
199 Err(XApiError::ApiError {
200 status: 0,
201 message: "not implemented".to_string(),
202 })
203 }
204
205 async fn delete_tweet(&self, _tweet_id: &str) -> Result<bool, XApiError> {
207 Err(XApiError::ApiError {
208 status: 0,
209 message: "not implemented".to_string(),
210 })
211 }
212
213 async fn get_home_timeline(
215 &self,
216 _user_id: &str,
217 _max_results: u32,
218 _pagination_token: Option<&str>,
219 ) -> Result<SearchResponse, XApiError> {
220 Err(XApiError::ApiError {
221 status: 0,
222 message: "not implemented".to_string(),
223 })
224 }
225
226 async fn unlike_tweet(&self, _user_id: &str, _tweet_id: &str) -> Result<bool, XApiError> {
228 Err(XApiError::ApiError {
229 status: 0,
230 message: "not implemented".to_string(),
231 })
232 }
233
234 async fn get_followers(
236 &self,
237 _user_id: &str,
238 _max_results: u32,
239 _pagination_token: Option<&str>,
240 ) -> Result<UsersResponse, XApiError> {
241 Err(XApiError::ApiError {
242 status: 0,
243 message: "not implemented".to_string(),
244 })
245 }
246
247 async fn get_following(
249 &self,
250 _user_id: &str,
251 _max_results: u32,
252 _pagination_token: Option<&str>,
253 ) -> Result<UsersResponse, XApiError> {
254 Err(XApiError::ApiError {
255 status: 0,
256 message: "not implemented".to_string(),
257 })
258 }
259
260 async fn get_user_by_id(&self, _user_id: &str) -> Result<User, XApiError> {
262 Err(XApiError::ApiError {
263 status: 0,
264 message: "not implemented".to_string(),
265 })
266 }
267
268 async fn get_liked_tweets(
270 &self,
271 _user_id: &str,
272 _max_results: u32,
273 _pagination_token: Option<&str>,
274 ) -> Result<SearchResponse, XApiError> {
275 Err(XApiError::ApiError {
276 status: 0,
277 message: "not implemented".to_string(),
278 })
279 }
280
281 async fn get_bookmarks(
283 &self,
284 _user_id: &str,
285 _max_results: u32,
286 _pagination_token: Option<&str>,
287 ) -> Result<SearchResponse, XApiError> {
288 Err(XApiError::ApiError {
289 status: 0,
290 message: "not implemented".to_string(),
291 })
292 }
293
294 async fn bookmark_tweet(&self, _user_id: &str, _tweet_id: &str) -> Result<bool, XApiError> {
296 Err(XApiError::ApiError {
297 status: 0,
298 message: "not implemented".to_string(),
299 })
300 }
301
302 async fn unbookmark_tweet(&self, _user_id: &str, _tweet_id: &str) -> Result<bool, XApiError> {
304 Err(XApiError::ApiError {
305 status: 0,
306 message: "not implemented".to_string(),
307 })
308 }
309
310 async fn get_users_by_ids(&self, _user_ids: &[&str]) -> Result<UsersResponse, XApiError> {
312 Err(XApiError::ApiError {
313 status: 0,
314 message: "not implemented".to_string(),
315 })
316 }
317
318 async fn get_tweet_liking_users(
320 &self,
321 _tweet_id: &str,
322 _max_results: u32,
323 _pagination_token: Option<&str>,
324 ) -> Result<UsersResponse, XApiError> {
325 Err(XApiError::ApiError {
326 status: 0,
327 message: "not implemented".to_string(),
328 })
329 }
330
331 async fn raw_request(
339 &self,
340 _method: &str,
341 _url: &str,
342 _query: Option<&[(String, String)]>,
343 _body: Option<&str>,
344 _headers: Option<&[(String, String)]>,
345 ) -> Result<RawApiResponse, XApiError> {
346 Err(XApiError::ApiError {
347 status: 0,
348 message: "raw_request not implemented".to_string(),
349 })
350 }
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356
357 struct StubClient;
359
360 #[async_trait::async_trait]
361 impl XApiClient for StubClient {
362 async fn search_tweets(
363 &self,
364 _q: &str,
365 _m: u32,
366 _s: Option<&str>,
367 _p: Option<&str>,
368 ) -> Result<SearchResponse, XApiError> {
369 Ok(SearchResponse {
370 data: vec![],
371 includes: None,
372 meta: SearchMeta {
373 newest_id: None,
374 oldest_id: None,
375 result_count: 0,
376 next_token: None,
377 },
378 })
379 }
380 async fn get_mentions(
381 &self,
382 _u: &str,
383 _s: Option<&str>,
384 _p: Option<&str>,
385 ) -> Result<MentionResponse, XApiError> {
386 Ok(MentionResponse {
387 data: vec![],
388 includes: None,
389 meta: SearchMeta {
390 newest_id: None,
391 oldest_id: None,
392 result_count: 0,
393 next_token: None,
394 },
395 })
396 }
397 async fn post_tweet(&self, text: &str) -> Result<PostedTweet, XApiError> {
398 Ok(PostedTweet {
399 id: "stub_id".to_string(),
400 text: text.to_string(),
401 })
402 }
403 async fn reply_to_tweet(
404 &self,
405 text: &str,
406 _reply_to: &str,
407 ) -> Result<PostedTweet, XApiError> {
408 Ok(PostedTweet {
409 id: "reply_id".to_string(),
410 text: text.to_string(),
411 })
412 }
413 async fn get_tweet(&self, _id: &str) -> Result<Tweet, XApiError> {
414 Err(XApiError::ApiError {
415 status: 404,
416 message: "not found".into(),
417 })
418 }
419 async fn get_me(&self) -> Result<User, XApiError> {
420 Ok(User {
421 id: "me".into(),
422 username: "stub".into(),
423 name: "Stub".into(),
424 profile_image_url: None,
425 description: None,
426 location: None,
427 url: None,
428 public_metrics: UserMetrics::default(),
429 })
430 }
431 async fn get_user_tweets(
432 &self,
433 _u: &str,
434 _m: u32,
435 _p: Option<&str>,
436 ) -> Result<SearchResponse, XApiError> {
437 Ok(SearchResponse {
438 data: vec![],
439 includes: None,
440 meta: SearchMeta {
441 newest_id: None,
442 oldest_id: None,
443 result_count: 0,
444 next_token: None,
445 },
446 })
447 }
448 async fn get_user_by_username(&self, _u: &str) -> Result<User, XApiError> {
449 Err(XApiError::ApiError {
450 status: 404,
451 message: "not found".into(),
452 })
453 }
454 }
455
456 #[tokio::test]
459 async fn upload_media_default_returns_error() {
460 let client = StubClient;
461 let result = client.upload_media(b"data", MediaType::Gif).await;
462 assert!(matches!(result, Err(XApiError::MediaUploadError { .. })));
463 }
464
465 #[tokio::test]
466 async fn post_tweet_with_media_delegates_to_post_tweet() {
467 let client = StubClient;
468 let result = client
469 .post_tweet_with_media("hello", &["media1".to_string()])
470 .await
471 .unwrap();
472 assert_eq!(result.text, "hello");
473 assert_eq!(result.id, "stub_id");
474 }
475
476 #[tokio::test]
477 async fn reply_to_tweet_with_media_delegates_to_reply() {
478 let client = StubClient;
479 let result = client
480 .reply_to_tweet_with_media("reply text", "tweet_123", &["m1".to_string()])
481 .await
482 .unwrap();
483 assert_eq!(result.text, "reply text");
484 assert_eq!(result.id, "reply_id");
485 }
486
487 #[tokio::test]
488 async fn quote_tweet_default_not_implemented() {
489 let client = StubClient;
490 let result = client.quote_tweet("text", "quoted_id").await;
491 assert!(result.is_err());
492 }
493
494 #[tokio::test]
495 async fn like_tweet_default_not_implemented() {
496 let client = StubClient;
497 let result = client.like_tweet("user1", "tweet1").await;
498 assert!(result.is_err());
499 }
500
501 #[tokio::test]
502 async fn follow_user_default_not_implemented() {
503 let client = StubClient;
504 let result = client.follow_user("u1", "u2").await;
505 assert!(result.is_err());
506 }
507
508 #[tokio::test]
509 async fn unfollow_user_default_not_implemented() {
510 let client = StubClient;
511 let result = client.unfollow_user("u1", "u2").await;
512 assert!(result.is_err());
513 }
514
515 #[tokio::test]
516 async fn retweet_default_not_implemented() {
517 let client = StubClient;
518 let result = client.retweet("u1", "t1").await;
519 assert!(result.is_err());
520 }
521
522 #[tokio::test]
523 async fn unretweet_default_not_implemented() {
524 let client = StubClient;
525 let result = client.unretweet("u1", "t1").await;
526 assert!(result.is_err());
527 }
528
529 #[tokio::test]
530 async fn delete_tweet_default_not_implemented() {
531 let client = StubClient;
532 let result = client.delete_tweet("t1").await;
533 assert!(result.is_err());
534 }
535
536 #[tokio::test]
537 async fn get_home_timeline_default_not_implemented() {
538 let client = StubClient;
539 let result = client.get_home_timeline("u1", 10, None).await;
540 assert!(result.is_err());
541 }
542
543 #[tokio::test]
544 async fn unlike_tweet_default_not_implemented() {
545 let client = StubClient;
546 let result = client.unlike_tweet("u1", "t1").await;
547 assert!(result.is_err());
548 }
549
550 #[tokio::test]
551 async fn get_followers_default_not_implemented() {
552 let client = StubClient;
553 let result = client.get_followers("u1", 10, None).await;
554 assert!(result.is_err());
555 }
556
557 #[tokio::test]
558 async fn get_following_default_not_implemented() {
559 let client = StubClient;
560 let result = client.get_following("u1", 10, None).await;
561 assert!(result.is_err());
562 }
563
564 #[tokio::test]
565 async fn get_user_by_id_default_not_implemented() {
566 let client = StubClient;
567 let result = client.get_user_by_id("u1").await;
568 assert!(result.is_err());
569 }
570
571 #[tokio::test]
572 async fn get_liked_tweets_default_not_implemented() {
573 let client = StubClient;
574 let result = client.get_liked_tweets("u1", 10, None).await;
575 assert!(result.is_err());
576 }
577
578 #[tokio::test]
579 async fn get_bookmarks_default_not_implemented() {
580 let client = StubClient;
581 let result = client.get_bookmarks("u1", 10, None).await;
582 assert!(result.is_err());
583 }
584
585 #[tokio::test]
586 async fn bookmark_tweet_default_not_implemented() {
587 let client = StubClient;
588 let result = client.bookmark_tweet("u1", "t1").await;
589 assert!(result.is_err());
590 }
591
592 #[tokio::test]
593 async fn unbookmark_tweet_default_not_implemented() {
594 let client = StubClient;
595 let result = client.unbookmark_tweet("u1", "t1").await;
596 assert!(result.is_err());
597 }
598
599 #[tokio::test]
600 async fn get_users_by_ids_default_not_implemented() {
601 let client = StubClient;
602 let result = client.get_users_by_ids(&["u1", "u2"]).await;
603 assert!(result.is_err());
604 }
605
606 #[tokio::test]
607 async fn get_tweet_liking_users_default_not_implemented() {
608 let client = StubClient;
609 let result = client.get_tweet_liking_users("t1", 10, None).await;
610 assert!(result.is_err());
611 }
612
613 #[tokio::test]
614 async fn raw_request_default_not_implemented() {
615 let client = StubClient;
616 let result = client.raw_request("GET", "/test", None, None, None).await;
617 assert!(result.is_err());
618 }
619
620 #[tokio::test]
623 async fn create_local_client_non_scraper_returns_none() {
624 let config = XApiConfig::default();
625 let result = create_local_client(&config).await;
626 assert!(result.is_none());
627 }
628
629 #[tokio::test]
630 async fn create_local_client_scraper_returns_some() {
631 let mut config = XApiConfig::default();
632 config.provider_backend = "scraper".to_string();
633 let result = create_local_client(&config).await;
634 assert!(result.is_some());
635 }
636
637 #[tokio::test]
638 async fn create_local_client_with_data_dir_non_scraper() {
639 let config = XApiConfig::default();
640 let dir = tempfile::tempdir().expect("temp dir");
641 let result = create_local_client_with_data_dir(&config, Some(dir.path())).await;
642 assert!(result.is_none());
643 }
644
645 #[tokio::test]
646 async fn create_local_client_with_data_dir_scraper() {
647 let mut config = XApiConfig::default();
648 config.provider_backend = "scraper".to_string();
649 let dir = tempfile::tempdir().expect("temp dir");
650 let result = create_local_client_with_data_dir(&config, Some(dir.path())).await;
651 assert!(result.is_some());
652 }
653
654 #[tokio::test]
655 async fn create_local_client_with_data_dir_none() {
656 let mut config = XApiConfig::default();
657 config.provider_backend = "scraper".to_string();
658 let result = create_local_client_with_data_dir(&config, None).await;
659 assert!(result.is_some());
660 }
661}