1use super::ToolkitError;
8use crate::x_api::types::PostedTweet;
9use crate::x_api::XApiClient;
10
11pub async fn post_tweet(
13 client: &dyn XApiClient,
14 text: &str,
15 media_ids: Option<&[String]>,
16) -> Result<PostedTweet, ToolkitError> {
17 super::validate_tweet_length(text)?;
18 match media_ids {
19 Some(ids) if !ids.is_empty() => Ok(client.post_tweet_with_media(text, ids).await?),
20 _ => Ok(client.post_tweet(text).await?),
21 }
22}
23
24pub async fn reply_to_tweet(
26 client: &dyn XApiClient,
27 text: &str,
28 in_reply_to_id: &str,
29 media_ids: Option<&[String]>,
30) -> Result<PostedTweet, ToolkitError> {
31 super::validate_tweet_length(text)?;
32 super::validate_id(in_reply_to_id, "in_reply_to_id")?;
33 match media_ids {
34 Some(ids) if !ids.is_empty() => Ok(client
35 .reply_to_tweet_with_media(text, in_reply_to_id, ids)
36 .await?),
37 _ => Ok(client.reply_to_tweet(text, in_reply_to_id).await?),
38 }
39}
40
41pub async fn quote_tweet(
43 client: &dyn XApiClient,
44 text: &str,
45 quoted_tweet_id: &str,
46) -> Result<PostedTweet, ToolkitError> {
47 super::validate_tweet_length(text)?;
48 super::validate_id(quoted_tweet_id, "quoted_tweet_id")?;
49 Ok(client.quote_tweet(text, quoted_tweet_id).await?)
50}
51
52pub async fn delete_tweet(client: &dyn XApiClient, tweet_id: &str) -> Result<bool, ToolkitError> {
54 super::validate_id(tweet_id, "tweet_id")?;
55 Ok(client.delete_tweet(tweet_id).await?)
56}
57
58pub async fn post_thread(
64 client: &dyn XApiClient,
65 tweets: &[String],
66 media_ids: Option<&[Vec<String>]>,
67) -> Result<Vec<String>, ToolkitError> {
68 if tweets.is_empty() {
69 return Err(ToolkitError::InvalidInput {
70 message: "thread must contain at least one tweet".into(),
71 });
72 }
73
74 for (i, text) in tweets.iter().enumerate() {
75 super::validate_tweet_length(text).map_err(|_| ToolkitError::InvalidInput {
76 message: format!(
77 "tweet {i} too long: {} characters (max {})",
78 text.len(),
79 super::MAX_TWEET_LENGTH
80 ),
81 })?;
82 }
83
84 let total = tweets.len();
85 let mut posted_ids: Vec<String> = Vec::with_capacity(total);
86
87 for (i, text) in tweets.iter().enumerate() {
88 let tweet_media = media_ids.and_then(|m| m.get(i)).map(|v| v.as_slice());
89
90 let result = if i == 0 {
91 match tweet_media {
92 Some(ids) if !ids.is_empty() => client.post_tweet_with_media(text, ids).await,
93 _ => client.post_tweet(text).await,
94 }
95 } else {
96 let prev = &posted_ids[i - 1];
97 match tweet_media {
98 Some(ids) if !ids.is_empty() => {
99 client.reply_to_tweet_with_media(text, prev, ids).await
100 }
101 _ => client.reply_to_tweet(text, prev).await,
102 }
103 };
104
105 match result {
106 Ok(posted) => posted_ids.push(posted.id),
107 Err(e) => {
108 let posted = posted_ids.len();
109 return Err(ToolkitError::ThreadPartialFailure {
110 posted_ids,
111 failed_index: i,
112 posted,
113 total,
114 source: Box::new(e),
115 });
116 }
117 }
118 }
119
120 Ok(posted_ids)
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126 use crate::error::XApiError;
127 use crate::x_api::types::*;
128 use std::sync::atomic::{AtomicUsize, Ordering};
129
130 struct MockClient;
131
132 #[async_trait::async_trait]
133 impl XApiClient for MockClient {
134 async fn search_tweets(
135 &self,
136 _: &str,
137 _: u32,
138 _: Option<&str>,
139 _: Option<&str>,
140 ) -> Result<SearchResponse, XApiError> {
141 unimplemented!()
142 }
143 async fn get_mentions(
144 &self,
145 _: &str,
146 _: Option<&str>,
147 _: Option<&str>,
148 ) -> Result<MentionResponse, XApiError> {
149 unimplemented!()
150 }
151 async fn post_tweet(&self, text: &str) -> Result<PostedTweet, XApiError> {
152 Ok(PostedTweet {
153 id: "t1".into(),
154 text: text.into(),
155 })
156 }
157 async fn reply_to_tweet(&self, text: &str, _: &str) -> Result<PostedTweet, XApiError> {
158 Ok(PostedTweet {
159 id: "t2".into(),
160 text: text.into(),
161 })
162 }
163 async fn get_tweet(&self, _: &str) -> Result<Tweet, XApiError> {
164 unimplemented!()
165 }
166 async fn get_me(&self) -> Result<User, XApiError> {
167 unimplemented!()
168 }
169 async fn get_user_tweets(
170 &self,
171 _: &str,
172 _: u32,
173 _: Option<&str>,
174 ) -> Result<SearchResponse, XApiError> {
175 unimplemented!()
176 }
177 async fn get_user_by_username(&self, _: &str) -> Result<User, XApiError> {
178 unimplemented!()
179 }
180 }
181
182 #[tokio::test]
183 async fn post_tweet_success() {
184 let r = post_tweet(&MockClient, "Hello", None).await.unwrap();
185 assert_eq!(r.id, "t1");
186 }
187
188 #[tokio::test]
189 async fn post_tweet_too_long() {
190 let text = "a".repeat(281);
191 let e = post_tweet(&MockClient, &text, None).await.unwrap_err();
192 assert!(matches!(e, ToolkitError::TweetTooLong { .. }));
193 }
194
195 #[tokio::test]
196 async fn reply_to_tweet_success() {
197 let r = reply_to_tweet(&MockClient, "Nice", "t0", None)
198 .await
199 .unwrap();
200 assert_eq!(r.id, "t2");
201 }
202
203 #[tokio::test]
204 async fn reply_to_tweet_empty_id() {
205 let e = reply_to_tweet(&MockClient, "Hi", "", None)
206 .await
207 .unwrap_err();
208 assert!(matches!(e, ToolkitError::InvalidInput { .. }));
209 }
210
211 #[tokio::test]
212 async fn delete_tweet_empty_id() {
213 let e = delete_tweet(&MockClient, "").await.unwrap_err();
214 assert!(matches!(e, ToolkitError::InvalidInput { .. }));
215 }
216
217 #[tokio::test]
218 async fn post_thread_success() {
219 let tweets = vec!["First".into(), "Second".into()];
220 let ids = post_thread(&MockClient, &tweets, None).await.unwrap();
221 assert_eq!(ids.len(), 2);
222 }
223
224 #[tokio::test]
225 async fn post_thread_empty() {
226 let e = post_thread(&MockClient, &[], None).await.unwrap_err();
227 assert!(matches!(e, ToolkitError::InvalidInput { .. }));
228 }
229
230 #[tokio::test]
231 async fn post_thread_tweet_too_long() {
232 let tweets = vec!["ok".into(), "a".repeat(281)];
233 let e = post_thread(&MockClient, &tweets, None).await.unwrap_err();
234 assert!(matches!(e, ToolkitError::InvalidInput { .. }));
235 }
236
237 #[tokio::test]
238 async fn post_thread_partial_failure() {
239 struct PartialClient {
240 calls: AtomicUsize,
241 }
242 #[async_trait::async_trait]
243 impl XApiClient for PartialClient {
244 async fn search_tweets(
245 &self,
246 _: &str,
247 _: u32,
248 _: Option<&str>,
249 _: Option<&str>,
250 ) -> Result<SearchResponse, XApiError> {
251 unimplemented!()
252 }
253 async fn get_mentions(
254 &self,
255 _: &str,
256 _: Option<&str>,
257 _: Option<&str>,
258 ) -> Result<MentionResponse, XApiError> {
259 unimplemented!()
260 }
261 async fn post_tweet(&self, _: &str) -> Result<PostedTweet, XApiError> {
262 let n = self.calls.fetch_add(1, Ordering::SeqCst);
263 Ok(PostedTweet {
264 id: format!("t{n}"),
265 text: "ok".into(),
266 })
267 }
268 async fn reply_to_tweet(&self, _: &str, _: &str) -> Result<PostedTweet, XApiError> {
269 Err(XApiError::ApiError {
270 status: 500,
271 message: "fail".into(),
272 })
273 }
274 async fn get_tweet(&self, _: &str) -> Result<Tweet, XApiError> {
275 unimplemented!()
276 }
277 async fn get_me(&self) -> Result<User, XApiError> {
278 unimplemented!()
279 }
280 async fn get_user_tweets(
281 &self,
282 _: &str,
283 _: u32,
284 _: Option<&str>,
285 ) -> Result<SearchResponse, XApiError> {
286 unimplemented!()
287 }
288 async fn get_user_by_username(&self, _: &str) -> Result<User, XApiError> {
289 unimplemented!()
290 }
291 }
292
293 let client = PartialClient {
294 calls: AtomicUsize::new(0),
295 };
296 let tweets = vec!["First".into(), "Second".into(), "Third".into()];
297 let e = post_thread(&client, &tweets, None).await.unwrap_err();
298 match e {
299 ToolkitError::ThreadPartialFailure {
300 posted_ids,
301 failed_index,
302 posted,
303 total,
304 ..
305 } => {
306 assert_eq!(posted_ids, vec!["t0"]);
307 assert_eq!(failed_index, 1);
308 assert_eq!(posted, 1);
309 assert_eq!(total, 3);
310 }
311 other => panic!("expected ThreadPartialFailure, got {other:?}"),
312 }
313 }
314}