Skip to main content

tuitbot_core/automation/adapters/
x_api.rs

1//! X API adapter implementations.
2
3use std::sync::Arc;
4
5use super::super::analytics_loop::{AnalyticsError, EngagementFetcher, ProfileFetcher};
6use super::super::loop_helpers::{
7    ContentLoopError, LoopError, LoopTweet, MentionsFetcher, ThreadPoster, TweetSearcher,
8};
9use super::super::posting_queue::PostExecutor;
10use super::super::target_loop::{TargetTweetFetcher, TargetUserManager};
11use super::helpers::{
12    search_response_to_loop_tweets, toolkit_to_analytics_error, toolkit_to_content_error,
13    toolkit_to_loop_error,
14};
15use crate::x_api::XApiClient;
16
17/// Adapts `XApiClient` to the `TweetSearcher` port trait via toolkit.
18pub struct XApiSearchAdapter {
19    client: Arc<dyn XApiClient>,
20}
21
22impl XApiSearchAdapter {
23    pub fn new(client: Arc<dyn XApiClient>) -> Self {
24        Self { client }
25    }
26}
27
28#[async_trait::async_trait]
29impl TweetSearcher for XApiSearchAdapter {
30    async fn search_tweets(&self, query: &str) -> Result<Vec<LoopTweet>, LoopError> {
31        let response = crate::toolkit::read::search_tweets(&*self.client, query, 20, None, None)
32            .await
33            .map_err(toolkit_to_loop_error)?;
34        Ok(search_response_to_loop_tweets(response))
35    }
36}
37
38/// Adapts `XApiClient` to the `MentionsFetcher` port trait via toolkit.
39pub struct XApiMentionsAdapter {
40    client: Arc<dyn XApiClient>,
41    own_user_id: String,
42}
43
44impl XApiMentionsAdapter {
45    pub fn new(client: Arc<dyn XApiClient>, own_user_id: String) -> Self {
46        Self {
47            client,
48            own_user_id,
49        }
50    }
51}
52
53#[async_trait::async_trait]
54impl MentionsFetcher for XApiMentionsAdapter {
55    async fn get_mentions(&self, since_id: Option<&str>) -> Result<Vec<LoopTweet>, LoopError> {
56        let response =
57            crate::toolkit::read::get_mentions(&*self.client, &self.own_user_id, since_id, None)
58                .await
59                .map_err(toolkit_to_loop_error)?;
60        Ok(search_response_to_loop_tweets(response))
61    }
62}
63
64/// Adapts `XApiClient` to `TargetTweetFetcher` and `TargetUserManager` via toolkit.
65pub struct XApiTargetAdapter {
66    client: Arc<dyn XApiClient>,
67}
68
69impl XApiTargetAdapter {
70    pub fn new(client: Arc<dyn XApiClient>) -> Self {
71        Self { client }
72    }
73}
74
75#[async_trait::async_trait]
76impl TargetTweetFetcher for XApiTargetAdapter {
77    async fn fetch_user_tweets(&self, user_id: &str) -> Result<Vec<LoopTweet>, LoopError> {
78        let response = crate::toolkit::read::get_user_tweets(&*self.client, user_id, 10, None)
79            .await
80            .map_err(toolkit_to_loop_error)?;
81        Ok(search_response_to_loop_tweets(response))
82    }
83}
84
85#[async_trait::async_trait]
86impl TargetUserManager for XApiTargetAdapter {
87    async fn lookup_user(&self, username: &str) -> Result<(String, String), LoopError> {
88        let user = crate::toolkit::read::get_user_by_username(&*self.client, username)
89            .await
90            .map_err(toolkit_to_loop_error)?;
91        Ok((user.id, user.username))
92    }
93}
94
95/// Adapts `XApiClient` to `ProfileFetcher` and `EngagementFetcher` via toolkit.
96pub struct XApiProfileAdapter {
97    client: Arc<dyn XApiClient>,
98}
99
100impl XApiProfileAdapter {
101    pub fn new(client: Arc<dyn XApiClient>) -> Self {
102        Self { client }
103    }
104}
105
106#[async_trait::async_trait]
107impl ProfileFetcher for XApiProfileAdapter {
108    async fn get_profile_metrics(
109        &self,
110    ) -> Result<super::super::analytics_loop::ProfileMetrics, AnalyticsError> {
111        let user = crate::toolkit::read::get_me(&*self.client)
112            .await
113            .map_err(toolkit_to_analytics_error)?;
114        Ok(super::super::analytics_loop::ProfileMetrics {
115            follower_count: user.public_metrics.followers_count as i64,
116            following_count: user.public_metrics.following_count as i64,
117            tweet_count: user.public_metrics.tweet_count as i64,
118        })
119    }
120}
121
122#[async_trait::async_trait]
123impl EngagementFetcher for XApiProfileAdapter {
124    async fn get_tweet_metrics(
125        &self,
126        tweet_id: &str,
127    ) -> Result<super::super::analytics_loop::TweetMetrics, AnalyticsError> {
128        let tweet = crate::toolkit::read::get_tweet(&*self.client, tweet_id)
129            .await
130            .map_err(toolkit_to_analytics_error)?;
131        Ok(super::super::analytics_loop::TweetMetrics {
132            likes: tweet.public_metrics.like_count as i64,
133            retweets: tweet.public_metrics.retweet_count as i64,
134            replies: tweet.public_metrics.reply_count as i64,
135            impressions: tweet.public_metrics.impression_count as i64,
136        })
137    }
138}
139
140/// Adapts `XApiClient` to `PostExecutor` (for the posting queue) via toolkit.
141pub struct XApiPostExecutorAdapter {
142    client: Arc<dyn XApiClient>,
143}
144
145impl XApiPostExecutorAdapter {
146    pub fn new(client: Arc<dyn XApiClient>) -> Self {
147        Self { client }
148    }
149}
150
151#[async_trait::async_trait]
152impl PostExecutor for XApiPostExecutorAdapter {
153    async fn execute_reply(
154        &self,
155        tweet_id: &str,
156        content: &str,
157        media_ids: &[String],
158    ) -> Result<String, String> {
159        let media = if media_ids.is_empty() {
160            None
161        } else {
162            Some(media_ids)
163        };
164        crate::toolkit::write::reply_to_tweet(&*self.client, content, tweet_id, media)
165            .await
166            .map(|posted| posted.id)
167            .map_err(|e| e.to_string())
168    }
169
170    async fn execute_tweet(&self, content: &str, media_ids: &[String]) -> Result<String, String> {
171        let media = if media_ids.is_empty() {
172            None
173        } else {
174            Some(media_ids)
175        };
176        crate::toolkit::write::post_tweet(&*self.client, content, media)
177            .await
178            .map(|posted| posted.id)
179            .map_err(|e| e.to_string())
180    }
181}
182
183/// Adapts `XApiClient` to `ThreadPoster` (for direct thread posting) via toolkit.
184pub struct XApiThreadPosterAdapter {
185    client: Arc<dyn XApiClient>,
186}
187
188impl XApiThreadPosterAdapter {
189    pub fn new(client: Arc<dyn XApiClient>) -> Self {
190        Self { client }
191    }
192}
193
194#[async_trait::async_trait]
195impl ThreadPoster for XApiThreadPosterAdapter {
196    async fn post_tweet(&self, content: &str) -> Result<String, ContentLoopError> {
197        crate::toolkit::write::post_tweet(&*self.client, content, None)
198            .await
199            .map(|posted| posted.id)
200            .map_err(toolkit_to_content_error)
201    }
202
203    async fn reply_to_tweet(
204        &self,
205        in_reply_to: &str,
206        content: &str,
207    ) -> Result<String, ContentLoopError> {
208        crate::toolkit::write::reply_to_tweet(&*self.client, content, in_reply_to, None)
209            .await
210            .map(|posted| posted.id)
211            .map_err(toolkit_to_content_error)
212    }
213}