Skip to main content

tuitbot_core/x_api/client/
trait_impl.rs

1//! `XApiClient` trait implementation for `XApiHttpClient`.
2//!
3//! Each method is a thin wrapper that builds request parameters and
4//! delegates to the HTTP helpers in `mod.rs` (`get`, `post_json`, `delete`).
5
6use crate::error::XApiError;
7use crate::x_api::types::{
8    ActionResultResponse, BookmarkTweetRequest, DeleteTweetResponse, FollowUserRequest,
9    LikeTweetRequest, MediaId, MediaPayload, MediaType, MentionResponse, PostTweetRequest,
10    PostTweetResponse, PostedTweet, RawApiResponse, ReplyTo, RetweetRequest, SearchResponse,
11    SingleTweetResponse, Tweet, User, UserResponse, UsersResponse,
12};
13use crate::x_api::XApiClient;
14
15use super::{XApiHttpClient, EXPANSIONS, TWEET_FIELDS, USER_FIELDS};
16
17#[async_trait::async_trait]
18impl XApiClient for XApiHttpClient {
19    async fn search_tweets(
20        &self,
21        query: &str,
22        max_results: u32,
23        since_id: Option<&str>,
24        pagination_token: Option<&str>,
25    ) -> Result<SearchResponse, XApiError> {
26        tracing::debug!(query = %query, max_results = max_results, "Search tweets");
27        let max_str = max_results.to_string();
28        let mut params = vec![
29            ("query", query),
30            ("max_results", &max_str),
31            ("tweet.fields", TWEET_FIELDS),
32            ("expansions", EXPANSIONS),
33            ("user.fields", USER_FIELDS),
34        ];
35
36        let since_id_owned;
37        if let Some(sid) = since_id {
38            since_id_owned = sid.to_string();
39            params.push(("since_id", &since_id_owned));
40        }
41
42        let pagination_token_owned;
43        if let Some(pt) = pagination_token {
44            pagination_token_owned = pt.to_string();
45            params.push(("pagination_token", &pagination_token_owned));
46        }
47
48        let response = self.get("/tweets/search/recent", &params).await?;
49        let resp: SearchResponse = response
50            .json()
51            .await
52            .map_err(|e| XApiError::Network { source: e })?;
53        tracing::debug!(
54            query = %query,
55            results = resp.data.len(),
56            "Search tweets completed",
57        );
58        Ok(resp)
59    }
60
61    async fn get_mentions(
62        &self,
63        user_id: &str,
64        since_id: Option<&str>,
65        pagination_token: Option<&str>,
66    ) -> Result<MentionResponse, XApiError> {
67        let path = format!("/users/{user_id}/mentions");
68        let mut params = vec![
69            ("tweet.fields", TWEET_FIELDS),
70            ("expansions", EXPANSIONS),
71            ("user.fields", USER_FIELDS),
72        ];
73
74        let since_id_owned;
75        if let Some(sid) = since_id {
76            since_id_owned = sid.to_string();
77            params.push(("since_id", &since_id_owned));
78        }
79
80        let pagination_token_owned;
81        if let Some(pt) = pagination_token {
82            pagination_token_owned = pt.to_string();
83            params.push(("pagination_token", &pagination_token_owned));
84        }
85
86        let response = self.get(&path, &params).await?;
87        response
88            .json::<MentionResponse>()
89            .await
90            .map_err(|e| XApiError::Network { source: e })
91    }
92
93    async fn post_tweet(&self, text: &str) -> Result<PostedTweet, XApiError> {
94        tracing::debug!(chars = text.len(), "Posting tweet");
95        let body = PostTweetRequest {
96            text: text.to_string(),
97            reply: None,
98            media: None,
99            quote_tweet_id: None,
100        };
101
102        let response = self.post_json("/tweets", &body).await?;
103        let resp: PostTweetResponse = response
104            .json()
105            .await
106            .map_err(|e| XApiError::Network { source: e })?;
107        Ok(resp.data)
108    }
109
110    async fn reply_to_tweet(
111        &self,
112        text: &str,
113        in_reply_to_id: &str,
114    ) -> Result<PostedTweet, XApiError> {
115        tracing::debug!(in_reply_to = %in_reply_to_id, chars = text.len(), "Posting reply");
116        let body = PostTweetRequest {
117            text: text.to_string(),
118            reply: Some(ReplyTo {
119                in_reply_to_tweet_id: in_reply_to_id.to_string(),
120            }),
121            media: None,
122            quote_tweet_id: None,
123        };
124
125        let response = self.post_json("/tweets", &body).await?;
126        let resp: PostTweetResponse = response
127            .json()
128            .await
129            .map_err(|e| XApiError::Network { source: e })?;
130        Ok(resp.data)
131    }
132
133    async fn upload_media(&self, data: &[u8], media_type: MediaType) -> Result<MediaId, XApiError> {
134        super::super::media::upload_media(
135            &self.client,
136            &self.upload_base_url,
137            &self.access_token.read().await,
138            data,
139            media_type,
140        )
141        .await
142    }
143
144    async fn post_tweet_with_media(
145        &self,
146        text: &str,
147        media_ids: &[String],
148    ) -> Result<PostedTweet, XApiError> {
149        tracing::debug!(
150            chars = text.len(),
151            media_count = media_ids.len(),
152            "Posting tweet with media"
153        );
154        let body = PostTweetRequest {
155            text: text.to_string(),
156            reply: None,
157            media: Some(MediaPayload {
158                media_ids: media_ids.to_vec(),
159            }),
160            quote_tweet_id: None,
161        };
162
163        let response = self.post_json("/tweets", &body).await?;
164        let resp: PostTweetResponse = response
165            .json()
166            .await
167            .map_err(|e| XApiError::Network { source: e })?;
168        Ok(resp.data)
169    }
170
171    async fn reply_to_tweet_with_media(
172        &self,
173        text: &str,
174        in_reply_to_id: &str,
175        media_ids: &[String],
176    ) -> Result<PostedTweet, XApiError> {
177        tracing::debug!(in_reply_to = %in_reply_to_id, chars = text.len(), media_count = media_ids.len(), "Posting reply with media");
178        let body = PostTweetRequest {
179            text: text.to_string(),
180            reply: Some(ReplyTo {
181                in_reply_to_tweet_id: in_reply_to_id.to_string(),
182            }),
183            media: Some(MediaPayload {
184                media_ids: media_ids.to_vec(),
185            }),
186            quote_tweet_id: None,
187        };
188
189        let response = self.post_json("/tweets", &body).await?;
190        let resp: PostTweetResponse = response
191            .json()
192            .await
193            .map_err(|e| XApiError::Network { source: e })?;
194        Ok(resp.data)
195    }
196
197    async fn get_tweet(&self, tweet_id: &str) -> Result<Tweet, XApiError> {
198        let path = format!("/tweets/{tweet_id}");
199        let params = [
200            ("tweet.fields", TWEET_FIELDS),
201            ("expansions", EXPANSIONS),
202            ("user.fields", USER_FIELDS),
203        ];
204
205        let response = self.get(&path, &params).await?;
206        let resp: SingleTweetResponse = response
207            .json()
208            .await
209            .map_err(|e| XApiError::Network { source: e })?;
210        Ok(resp.data)
211    }
212
213    async fn get_me(&self) -> Result<User, XApiError> {
214        let params = [("user.fields", USER_FIELDS)];
215
216        let response = self.get("/users/me", &params).await?;
217        let resp: UserResponse = response
218            .json()
219            .await
220            .map_err(|e| XApiError::Network { source: e })?;
221        Ok(resp.data)
222    }
223
224    async fn get_user_tweets(
225        &self,
226        user_id: &str,
227        max_results: u32,
228        pagination_token: Option<&str>,
229    ) -> Result<SearchResponse, XApiError> {
230        let path = format!("/users/{user_id}/tweets");
231        let max_str = max_results.to_string();
232        let mut params = vec![
233            ("max_results", max_str.as_str()),
234            ("tweet.fields", TWEET_FIELDS),
235            ("expansions", EXPANSIONS),
236            ("user.fields", USER_FIELDS),
237        ];
238
239        let pagination_token_owned;
240        if let Some(pt) = pagination_token {
241            pagination_token_owned = pt.to_string();
242            params.push(("pagination_token", &pagination_token_owned));
243        }
244
245        let response = self.get(&path, &params).await?;
246        response
247            .json::<SearchResponse>()
248            .await
249            .map_err(|e| XApiError::Network { source: e })
250    }
251
252    async fn get_user_by_username(&self, username: &str) -> Result<User, XApiError> {
253        let path = format!("/users/by/username/{username}");
254        let params = [("user.fields", USER_FIELDS)];
255
256        let response = self.get(&path, &params).await?;
257        let resp: UserResponse = response
258            .json()
259            .await
260            .map_err(|e| XApiError::Network { source: e })?;
261        Ok(resp.data)
262    }
263
264    async fn quote_tweet(
265        &self,
266        text: &str,
267        quoted_tweet_id: &str,
268    ) -> Result<PostedTweet, XApiError> {
269        tracing::debug!(chars = text.len(), quoted = %quoted_tweet_id, "Posting quote tweet");
270        let body = PostTweetRequest {
271            text: text.to_string(),
272            reply: None,
273            media: None,
274            quote_tweet_id: Some(quoted_tweet_id.to_string()),
275        };
276
277        let response = self.post_json("/tweets", &body).await?;
278        let resp: PostTweetResponse = response
279            .json()
280            .await
281            .map_err(|e| XApiError::Network { source: e })?;
282        Ok(resp.data)
283    }
284
285    async fn like_tweet(&self, user_id: &str, tweet_id: &str) -> Result<bool, XApiError> {
286        tracing::debug!(user_id = %user_id, tweet_id = %tweet_id, "Liking tweet");
287        let path = format!("/users/{user_id}/likes");
288        let body = LikeTweetRequest {
289            tweet_id: tweet_id.to_string(),
290        };
291
292        let response = self.post_json(&path, &body).await?;
293        let resp: ActionResultResponse = response
294            .json()
295            .await
296            .map_err(|e| XApiError::Network { source: e })?;
297        Ok(resp.data.result)
298    }
299
300    async fn follow_user(&self, user_id: &str, target_user_id: &str) -> Result<bool, XApiError> {
301        tracing::debug!(user_id = %user_id, target = %target_user_id, "Following user");
302        let path = format!("/users/{user_id}/following");
303        let body = FollowUserRequest {
304            target_user_id: target_user_id.to_string(),
305        };
306
307        let response = self.post_json(&path, &body).await?;
308        let resp: ActionResultResponse = response
309            .json()
310            .await
311            .map_err(|e| XApiError::Network { source: e })?;
312        Ok(resp.data.result)
313    }
314
315    async fn unfollow_user(&self, user_id: &str, target_user_id: &str) -> Result<bool, XApiError> {
316        tracing::debug!(user_id = %user_id, target = %target_user_id, "Unfollowing user");
317        let path = format!("/users/{user_id}/following/{target_user_id}");
318
319        let response = self.delete(&path).await?;
320        let resp: ActionResultResponse = response
321            .json()
322            .await
323            .map_err(|e| XApiError::Network { source: e })?;
324        Ok(resp.data.result)
325    }
326
327    async fn retweet(&self, user_id: &str, tweet_id: &str) -> Result<bool, XApiError> {
328        tracing::debug!(user_id = %user_id, tweet_id = %tweet_id, "Retweeting");
329        let path = format!("/users/{user_id}/retweets");
330        let body = RetweetRequest {
331            tweet_id: tweet_id.to_string(),
332        };
333
334        let response = self.post_json(&path, &body).await?;
335        let resp: ActionResultResponse = response
336            .json()
337            .await
338            .map_err(|e| XApiError::Network { source: e })?;
339        Ok(resp.data.result)
340    }
341
342    async fn unretweet(&self, user_id: &str, tweet_id: &str) -> Result<bool, XApiError> {
343        tracing::debug!(user_id = %user_id, tweet_id = %tweet_id, "Unretweeting");
344        let path = format!("/users/{user_id}/retweets/{tweet_id}");
345
346        let response = self.delete(&path).await?;
347        let resp: ActionResultResponse = response
348            .json()
349            .await
350            .map_err(|e| XApiError::Network { source: e })?;
351        Ok(resp.data.result)
352    }
353
354    async fn delete_tweet(&self, tweet_id: &str) -> Result<bool, XApiError> {
355        tracing::debug!(tweet_id = %tweet_id, "Deleting tweet");
356        let path = format!("/tweets/{tweet_id}");
357
358        let response = self.delete(&path).await?;
359        let resp: DeleteTweetResponse = response
360            .json()
361            .await
362            .map_err(|e| XApiError::Network { source: e })?;
363        Ok(resp.data.deleted)
364    }
365
366    async fn get_home_timeline(
367        &self,
368        user_id: &str,
369        max_results: u32,
370        pagination_token: Option<&str>,
371    ) -> Result<SearchResponse, XApiError> {
372        tracing::debug!(user_id = %user_id, max_results = max_results, "Getting home timeline");
373        let path = format!("/users/{user_id}/timelines/reverse_chronological");
374        let max_str = max_results.to_string();
375        let mut params = vec![
376            ("max_results", max_str.as_str()),
377            ("tweet.fields", TWEET_FIELDS),
378            ("expansions", EXPANSIONS),
379            ("user.fields", USER_FIELDS),
380        ];
381
382        let pagination_token_owned;
383        if let Some(pt) = pagination_token {
384            pagination_token_owned = pt.to_string();
385            params.push(("pagination_token", &pagination_token_owned));
386        }
387
388        let response = self.get(&path, &params).await?;
389        response
390            .json::<SearchResponse>()
391            .await
392            .map_err(|e| XApiError::Network { source: e })
393    }
394
395    async fn unlike_tweet(&self, user_id: &str, tweet_id: &str) -> Result<bool, XApiError> {
396        tracing::debug!(user_id = %user_id, tweet_id = %tweet_id, "Unliking tweet");
397        let path = format!("/users/{user_id}/likes/{tweet_id}");
398
399        let response = self.delete(&path).await?;
400        let resp: ActionResultResponse = response
401            .json()
402            .await
403            .map_err(|e| XApiError::Network { source: e })?;
404        Ok(resp.data.result)
405    }
406
407    async fn get_followers(
408        &self,
409        user_id: &str,
410        max_results: u32,
411        pagination_token: Option<&str>,
412    ) -> Result<UsersResponse, XApiError> {
413        tracing::debug!(user_id = %user_id, max_results = max_results, "Getting followers");
414        let path = format!("/users/{user_id}/followers");
415        let max_str = max_results.to_string();
416        let mut params = vec![
417            ("max_results", max_str.as_str()),
418            ("user.fields", USER_FIELDS),
419        ];
420
421        let pagination_token_owned;
422        if let Some(pt) = pagination_token {
423            pagination_token_owned = pt.to_string();
424            params.push(("pagination_token", &pagination_token_owned));
425        }
426
427        let response = self.get(&path, &params).await?;
428        response
429            .json::<UsersResponse>()
430            .await
431            .map_err(|e| XApiError::Network { source: e })
432    }
433
434    async fn get_following(
435        &self,
436        user_id: &str,
437        max_results: u32,
438        pagination_token: Option<&str>,
439    ) -> Result<UsersResponse, XApiError> {
440        tracing::debug!(user_id = %user_id, max_results = max_results, "Getting following");
441        let path = format!("/users/{user_id}/following");
442        let max_str = max_results.to_string();
443        let mut params = vec![
444            ("max_results", max_str.as_str()),
445            ("user.fields", USER_FIELDS),
446        ];
447
448        let pagination_token_owned;
449        if let Some(pt) = pagination_token {
450            pagination_token_owned = pt.to_string();
451            params.push(("pagination_token", &pagination_token_owned));
452        }
453
454        let response = self.get(&path, &params).await?;
455        response
456            .json::<UsersResponse>()
457            .await
458            .map_err(|e| XApiError::Network { source: e })
459    }
460
461    async fn get_user_by_id(&self, user_id: &str) -> Result<User, XApiError> {
462        tracing::debug!(user_id = %user_id, "Getting user by ID");
463        let path = format!("/users/{user_id}");
464        let params = [("user.fields", USER_FIELDS)];
465
466        let response = self.get(&path, &params).await?;
467        let resp: UserResponse = response
468            .json()
469            .await
470            .map_err(|e| XApiError::Network { source: e })?;
471        Ok(resp.data)
472    }
473
474    async fn get_liked_tweets(
475        &self,
476        user_id: &str,
477        max_results: u32,
478        pagination_token: Option<&str>,
479    ) -> Result<SearchResponse, XApiError> {
480        tracing::debug!(user_id = %user_id, max_results = max_results, "Getting liked tweets");
481        let path = format!("/users/{user_id}/liked_tweets");
482        let max_str = max_results.to_string();
483        let mut params = vec![
484            ("max_results", max_str.as_str()),
485            ("tweet.fields", TWEET_FIELDS),
486            ("expansions", EXPANSIONS),
487            ("user.fields", USER_FIELDS),
488        ];
489
490        let pagination_token_owned;
491        if let Some(pt) = pagination_token {
492            pagination_token_owned = pt.to_string();
493            params.push(("pagination_token", &pagination_token_owned));
494        }
495
496        let response = self.get(&path, &params).await?;
497        response
498            .json::<SearchResponse>()
499            .await
500            .map_err(|e| XApiError::Network { source: e })
501    }
502
503    async fn get_bookmarks(
504        &self,
505        user_id: &str,
506        max_results: u32,
507        pagination_token: Option<&str>,
508    ) -> Result<SearchResponse, XApiError> {
509        tracing::debug!(user_id = %user_id, max_results = max_results, "Getting bookmarks");
510        let path = format!("/users/{user_id}/bookmarks");
511        let max_str = max_results.to_string();
512        let mut params = vec![
513            ("max_results", max_str.as_str()),
514            ("tweet.fields", TWEET_FIELDS),
515            ("expansions", EXPANSIONS),
516            ("user.fields", USER_FIELDS),
517        ];
518
519        let pagination_token_owned;
520        if let Some(pt) = pagination_token {
521            pagination_token_owned = pt.to_string();
522            params.push(("pagination_token", &pagination_token_owned));
523        }
524
525        let response = self.get(&path, &params).await?;
526        response
527            .json::<SearchResponse>()
528            .await
529            .map_err(|e| XApiError::Network { source: e })
530    }
531
532    async fn bookmark_tweet(&self, user_id: &str, tweet_id: &str) -> Result<bool, XApiError> {
533        tracing::debug!(user_id = %user_id, tweet_id = %tweet_id, "Bookmarking tweet");
534        let path = format!("/users/{user_id}/bookmarks");
535        let body = BookmarkTweetRequest {
536            tweet_id: tweet_id.to_string(),
537        };
538
539        let response = self.post_json(&path, &body).await?;
540        let resp: ActionResultResponse = response
541            .json()
542            .await
543            .map_err(|e| XApiError::Network { source: e })?;
544        Ok(resp.data.result)
545    }
546
547    async fn unbookmark_tweet(&self, user_id: &str, tweet_id: &str) -> Result<bool, XApiError> {
548        tracing::debug!(user_id = %user_id, tweet_id = %tweet_id, "Unbookmarking tweet");
549        let path = format!("/users/{user_id}/bookmarks/{tweet_id}");
550
551        let response = self.delete(&path).await?;
552        let resp: ActionResultResponse = response
553            .json()
554            .await
555            .map_err(|e| XApiError::Network { source: e })?;
556        Ok(resp.data.result)
557    }
558
559    async fn get_users_by_ids(&self, user_ids: &[&str]) -> Result<UsersResponse, XApiError> {
560        tracing::debug!(count = user_ids.len(), "Getting users by IDs");
561        let ids = user_ids.join(",");
562        let params = [("ids", ids.as_str()), ("user.fields", USER_FIELDS)];
563
564        let response = self.get("/users", &params).await?;
565        response
566            .json::<UsersResponse>()
567            .await
568            .map_err(|e| XApiError::Network { source: e })
569    }
570
571    async fn get_tweet_liking_users(
572        &self,
573        tweet_id: &str,
574        max_results: u32,
575        pagination_token: Option<&str>,
576    ) -> Result<UsersResponse, XApiError> {
577        tracing::debug!(tweet_id = %tweet_id, max_results = max_results, "Getting liking users");
578        let path = format!("/tweets/{tweet_id}/liking_users");
579        let max_str = max_results.to_string();
580        let mut params = vec![
581            ("max_results", max_str.as_str()),
582            ("user.fields", USER_FIELDS),
583        ];
584
585        let pagination_token_owned;
586        if let Some(pt) = pagination_token {
587            pagination_token_owned = pt.to_string();
588            params.push(("pagination_token", &pagination_token_owned));
589        }
590
591        let response = self.get(&path, &params).await?;
592        response
593            .json::<UsersResponse>()
594            .await
595            .map_err(|e| XApiError::Network { source: e })
596    }
597
598    async fn raw_request(
599        &self,
600        method: &str,
601        url: &str,
602        query: Option<&[(String, String)]>,
603        body: Option<&str>,
604        headers: Option<&[(String, String)]>,
605    ) -> Result<RawApiResponse, XApiError> {
606        let token = self.access_token.read().await;
607        let req_method = match method.to_ascii_uppercase().as_str() {
608            "GET" => reqwest::Method::GET,
609            "POST" => reqwest::Method::POST,
610            "PUT" => reqwest::Method::PUT,
611            "DELETE" => reqwest::Method::DELETE,
612            other => {
613                return Err(XApiError::ApiError {
614                    status: 0,
615                    message: format!("unsupported HTTP method: {other}"),
616                })
617            }
618        };
619
620        let mut builder = self.client.request(req_method, url).bearer_auth(&*token);
621
622        if let Some(pairs) = query {
623            builder = builder.query(pairs);
624        }
625        if let Some(json_body) = body {
626            builder = builder
627                .header("Content-Type", "application/json")
628                .body(json_body.to_string());
629        }
630        if let Some(extra_headers) = headers {
631            for (k, v) in extra_headers {
632                builder = builder.header(k.as_str(), v.as_str());
633            }
634        }
635
636        let response = builder
637            .send()
638            .await
639            .map_err(|e| XApiError::Network { source: e })?;
640
641        let status = response.status().as_u16();
642        let rate_limit = Self::parse_rate_limit_headers(response.headers());
643
644        // Extract a small set of useful headers.
645        let mut resp_headers = std::collections::HashMap::new();
646        for key in [
647            "content-type",
648            "x-rate-limit-remaining",
649            "x-rate-limit-reset",
650            "x-rate-limit-limit",
651        ] {
652            if let Some(val) = response.headers().get(key) {
653                if let Ok(s) = val.to_str() {
654                    resp_headers.insert(key.to_string(), s.to_string());
655                }
656            }
657        }
658
659        // Extract path for usage tracking (best-effort parse from URL).
660        if let Ok(parsed) = reqwest::Url::parse(url) {
661            self.record_usage(parsed.path(), method, status);
662        }
663
664        let response_body = response
665            .text()
666            .await
667            .map_err(|e| XApiError::Network { source: e })?;
668
669        Ok(RawApiResponse {
670            status,
671            headers: resp_headers,
672            body: response_body,
673            rate_limit: Some(rate_limit),
674        })
675    }
676}