1use std::collections::HashMap;
9
10use serde::Serialize;
11
12use super::request::{ApiClient, CallOptions, RequestTarget};
13use super::response::types::{
14 ApiResponse, BookmarkedResult, DeletedResult, DmEvent, FollowingResult, LikedResult,
15 MutingResult, RetweetedResult, Tweet, UsageData, User, deserialize_response,
16};
17use crate::error::Result;
18
19#[derive(Serialize)]
22struct PostBody {
23 text: String,
24 #[serde(skip_serializing_if = "Option::is_none")]
25 reply: Option<PostReply>,
26 #[serde(rename = "quote_tweet_id", skip_serializing_if = "Option::is_none")]
27 quote: Option<String>,
28 #[serde(skip_serializing_if = "Option::is_none")]
29 media: Option<PostMedia>,
30}
31
32#[derive(Serialize)]
33struct PostReply {
34 #[serde(rename = "in_reply_to_tweet_id")]
35 in_reply_to_post_id: String,
36}
37
38#[derive(Serialize)]
39struct PostMedia {
40 media_ids: Vec<String>,
41}
42
43#[must_use]
47pub fn resolve_post_id(input: &str) -> String {
48 let input = input.trim();
49
50 if (input.starts_with("http://") || input.starts_with("https://"))
51 && let Ok(parsed) = url::Url::parse(input)
52 {
53 let parts: Vec<&str> = parsed.path().trim_matches('/').split('/').collect();
54 for (i, p) in parts.iter().enumerate() {
55 if *p == "status" && i + 1 < parts.len() {
56 return parts[i + 1].to_string();
57 }
58 }
59 }
60 input.to_string()
61}
62
63#[must_use]
65pub fn resolve_username(input: &str) -> String {
66 input.trim().trim_start_matches('@').to_string()
67}
68
69pub const POST_BODY_MAX_CHARS: usize = 280;
77
78pub const POST_MEDIA_MAX: usize = 4;
80
81pub fn validate_post_body(text: &str) -> std::result::Result<(), &'static str> {
86 if text.is_empty() {
87 return Err("empty-body");
88 }
89 if text.chars().count() > POST_BODY_MAX_CHARS {
90 return Err("body-too-long");
91 }
92 Ok(())
93}
94
95pub fn validate_media_attachments(ids: &[String]) -> std::result::Result<(), &'static str> {
100 if ids.len() > POST_MEDIA_MAX {
101 return Err("too-many-attachments");
102 }
103 Ok(())
104}
105
106pub fn validate_dm_body(text: &str) -> std::result::Result<(), &'static str> {
111 if text.is_empty() {
112 return Err("empty-body");
113 }
114 Ok(())
115}
116
117pub fn validate_target_username(input: &str) -> std::result::Result<(), &'static str> {
122 if resolve_username(input).is_empty() {
123 return Err("empty-username");
124 }
125 Ok(())
126}
127
128pub fn validate_post_id(input: &str) -> std::result::Result<(), &'static str> {
133 if resolve_post_id(input).is_empty() {
134 return Err("empty-post-id");
135 }
136 Ok(())
137}
138
139fn build_query(opts: &CallOptions) -> Vec<(String, String)> {
147 if opts.pagination_token.is_empty() {
148 Vec::new()
149 } else {
150 vec![(
151 "pagination_token".to_string(),
152 opts.pagination_token.clone(),
153 )]
154 }
155}
156
157impl ApiClient {
158 pub fn create_post(
180 &mut self,
181 text: &str,
182 media_ids: &[String],
183 opts: &CallOptions,
184 ) -> Result<ApiResponse<Tweet>> {
185 let mut body = PostBody {
186 text: text.to_string(),
187 reply: None,
188 quote: None,
189 media: None,
190 };
191 if !media_ids.is_empty() {
192 body.media = Some(PostMedia {
193 media_ids: media_ids.to_vec(),
194 });
195 }
196
197 let data = serde_json::to_string(&body)?;
198 let mut req = opts.to_request_options();
199 req.method = "POST".to_string();
200 req.target = RequestTarget::Template {
201 path: "/2/tweets".to_string(),
202 path_params: HashMap::new(),
203 query: Vec::new(),
204 };
205 req.data = data;
206
207 deserialize_response(self.send_request(&req)?)
208 }
209
210 pub fn reply_to_post(
216 &mut self,
217 post_id: &str,
218 text: &str,
219 media_ids: &[String],
220 opts: &CallOptions,
221 ) -> Result<ApiResponse<Tweet>> {
222 let post_id = resolve_post_id(post_id);
223 let mut body = PostBody {
224 text: text.to_string(),
225 reply: Some(PostReply {
226 in_reply_to_post_id: post_id,
227 }),
228 quote: None,
229 media: None,
230 };
231 if !media_ids.is_empty() {
232 body.media = Some(PostMedia {
233 media_ids: media_ids.to_vec(),
234 });
235 }
236
237 let data = serde_json::to_string(&body)?;
238 let mut req = opts.to_request_options();
239 req.method = "POST".to_string();
240 req.target = RequestTarget::Template {
241 path: "/2/tweets".to_string(),
242 path_params: HashMap::new(),
243 query: Vec::new(),
244 };
245 req.data = data;
246
247 deserialize_response(self.send_request(&req)?)
248 }
249
250 pub fn quote_post(
256 &mut self,
257 post_id: &str,
258 text: &str,
259 opts: &CallOptions,
260 ) -> Result<ApiResponse<Tweet>> {
261 let post_id = resolve_post_id(post_id);
262 let body = PostBody {
263 text: text.to_string(),
264 reply: None,
265 quote: Some(post_id),
266 media: None,
267 };
268
269 let data = serde_json::to_string(&body)?;
270 let mut req = opts.to_request_options();
271 req.method = "POST".to_string();
272 req.target = RequestTarget::Template {
273 path: "/2/tweets".to_string(),
274 path_params: HashMap::new(),
275 query: Vec::new(),
276 };
277 req.data = data;
278
279 deserialize_response(self.send_request(&req)?)
280 }
281
282 pub fn delete_post(
288 &mut self,
289 post_id: &str,
290 opts: &CallOptions,
291 ) -> Result<ApiResponse<DeletedResult>> {
292 let post_id = resolve_post_id(post_id);
293 let mut req = opts.to_request_options();
294 req.method = "DELETE".to_string();
295 req.target = RequestTarget::Template {
296 path: "/2/tweets/{id}".to_string(),
297 path_params: HashMap::from([("id".to_string(), post_id)]),
298 query: Vec::new(),
299 };
300 req.data.clear();
301
302 deserialize_response(self.send_request(&req)?)
303 }
304
305 pub fn read_post(&mut self, post_id: &str, opts: &CallOptions) -> Result<ApiResponse<Tweet>> {
311 let post_id = resolve_post_id(post_id);
312 let mut req = opts.to_request_options();
313 req.method = "GET".to_string();
314 req.target = RequestTarget::Template {
315 path: "/2/tweets/{id}".to_string(),
316 path_params: HashMap::from([("id".to_string(), post_id)]),
317 query: vec![
318 (
319 "tweet.fields".to_string(),
320 "created_at,public_metrics,conversation_id,in_reply_to_user_id,referenced_tweets,entities,attachments".to_string(),
321 ),
322 (
323 "expansions".to_string(),
324 "author_id,referenced_tweets.id".to_string(),
325 ),
326 (
327 "user.fields".to_string(),
328 "username,name,verified".to_string(),
329 ),
330 ],
331 };
332 req.data.clear();
333
334 deserialize_response(self.send_request(&req)?)
335 }
336
337 pub fn search_posts(
361 &mut self,
362 query: &str,
363 max_results: i32,
364 opts: &CallOptions,
365 ) -> Result<ApiResponse<Vec<Tweet>>> {
366 let max_results = max_results.clamp(10, 100);
367
368 let mut q = vec![
369 ("query".to_string(), query.to_string()),
370 ("max_results".to_string(), max_results.to_string()),
371 (
372 "tweet.fields".to_string(),
373 "created_at,public_metrics,conversation_id,entities".to_string(),
374 ),
375 ("expansions".to_string(), "author_id".to_string()),
376 (
377 "user.fields".to_string(),
378 "username,name,verified".to_string(),
379 ),
380 ];
381 q.extend(build_query(opts));
382
383 let mut req = opts.to_request_options();
384 req.method = "GET".to_string();
385 req.target = RequestTarget::Template {
386 path: "/2/tweets/search/recent".to_string(),
387 path_params: HashMap::new(),
388 query: q,
389 };
390 req.data.clear();
391
392 deserialize_response(self.send_request(&req)?)
393 }
394
395 pub fn get_me(&mut self, opts: &CallOptions) -> Result<ApiResponse<User>> {
417 let mut req = opts.to_request_options();
418 req.method = "GET".to_string();
419 req.target = RequestTarget::Template {
420 path: "/2/users/me".to_string(),
421 path_params: HashMap::new(),
422 query: vec![(
423 "user.fields".to_string(),
424 "created_at,description,public_metrics,verified,profile_image_url".to_string(),
425 )],
426 };
427 req.data.clear();
428
429 deserialize_response(self.send_request(&req)?)
430 }
431
432 pub fn lookup_user(&mut self, username: &str, opts: &CallOptions) -> Result<ApiResponse<User>> {
438 let username = resolve_username(username);
439 let mut req = opts.to_request_options();
440 req.method = "GET".to_string();
441 req.target = RequestTarget::Template {
442 path: "/2/users/by/username/{username}".to_string(),
443 path_params: HashMap::from([("username".to_string(), username)]),
444 query: vec![(
445 "user.fields".to_string(),
446 "created_at,description,public_metrics,verified,profile_image_url".to_string(),
447 )],
448 };
449 req.data.clear();
450
451 deserialize_response(self.send_request(&req)?)
452 }
453
454 pub fn get_timeline(
460 &mut self,
461 user_id: &str,
462 max_results: i32,
463 opts: &CallOptions,
464 ) -> Result<ApiResponse<Vec<Tweet>>> {
465 let mut q = vec![
466 ("max_results".to_string(), max_results.to_string()),
467 (
468 "tweet.fields".to_string(),
469 "created_at,public_metrics,conversation_id,entities".to_string(),
470 ),
471 ("expansions".to_string(), "author_id".to_string()),
472 ("user.fields".to_string(), "username,name".to_string()),
473 ];
474 q.extend(build_query(opts));
475
476 let mut req = opts.to_request_options();
477 req.method = "GET".to_string();
478 req.target = RequestTarget::Template {
479 path: "/2/users/{id}/timelines/reverse_chronological".to_string(),
480 path_params: HashMap::from([("id".to_string(), user_id.to_string())]),
481 query: q,
482 };
483 req.data.clear();
484
485 deserialize_response(self.send_request(&req)?)
486 }
487
488 pub fn get_mentions(
494 &mut self,
495 user_id: &str,
496 max_results: i32,
497 opts: &CallOptions,
498 ) -> Result<ApiResponse<Vec<Tweet>>> {
499 let mut q = vec![
500 ("max_results".to_string(), max_results.to_string()),
501 (
502 "tweet.fields".to_string(),
503 "created_at,public_metrics,conversation_id,entities".to_string(),
504 ),
505 ("expansions".to_string(), "author_id".to_string()),
506 ("user.fields".to_string(), "username,name".to_string()),
507 ];
508 q.extend(build_query(opts));
509
510 let mut req = opts.to_request_options();
511 req.method = "GET".to_string();
512 req.target = RequestTarget::Template {
513 path: "/2/users/{id}/mentions".to_string(),
514 path_params: HashMap::from([("id".to_string(), user_id.to_string())]),
515 query: q,
516 };
517 req.data.clear();
518
519 deserialize_response(self.send_request(&req)?)
520 }
521
522 pub fn like_post(
528 &mut self,
529 user_id: &str,
530 post_id: &str,
531 opts: &CallOptions,
532 ) -> Result<ApiResponse<LikedResult>> {
533 let post_id = resolve_post_id(post_id);
534 let mut req = opts.to_request_options();
535 req.method = "POST".to_string();
536 req.target = RequestTarget::Template {
537 path: "/2/users/{id}/likes".to_string(),
538 path_params: HashMap::from([("id".to_string(), user_id.to_string())]),
539 query: Vec::new(),
540 };
541 req.data = format!(r#"{{"tweet_id":"{post_id}"}}"#);
542
543 deserialize_response(self.send_request(&req)?)
544 }
545
546 pub fn unlike_post(
552 &mut self,
553 user_id: &str,
554 post_id: &str,
555 opts: &CallOptions,
556 ) -> Result<ApiResponse<LikedResult>> {
557 let post_id = resolve_post_id(post_id);
558 let mut req = opts.to_request_options();
559 req.method = "DELETE".to_string();
560 req.target = RequestTarget::Template {
561 path: "/2/users/{id}/likes/{tweet_id}".to_string(),
562 path_params: HashMap::from([
563 ("id".to_string(), user_id.to_string()),
564 ("tweet_id".to_string(), post_id),
565 ]),
566 query: Vec::new(),
567 };
568 req.data.clear();
569
570 deserialize_response(self.send_request(&req)?)
571 }
572
573 pub fn repost(
579 &mut self,
580 user_id: &str,
581 post_id: &str,
582 opts: &CallOptions,
583 ) -> Result<ApiResponse<RetweetedResult>> {
584 let post_id = resolve_post_id(post_id);
585 let mut req = opts.to_request_options();
586 req.method = "POST".to_string();
587 req.target = RequestTarget::Template {
588 path: "/2/users/{id}/retweets".to_string(),
589 path_params: HashMap::from([("id".to_string(), user_id.to_string())]),
590 query: Vec::new(),
591 };
592 req.data = format!(r#"{{"tweet_id":"{post_id}"}}"#);
593
594 deserialize_response(self.send_request(&req)?)
595 }
596
597 pub fn unrepost(
603 &mut self,
604 user_id: &str,
605 post_id: &str,
606 opts: &CallOptions,
607 ) -> Result<ApiResponse<RetweetedResult>> {
608 let post_id = resolve_post_id(post_id);
609 let mut req = opts.to_request_options();
610 req.method = "DELETE".to_string();
611 req.target = RequestTarget::Template {
612 path: "/2/users/{id}/retweets/{source_tweet_id}".to_string(),
613 path_params: HashMap::from([
614 ("id".to_string(), user_id.to_string()),
615 ("source_tweet_id".to_string(), post_id),
616 ]),
617 query: Vec::new(),
618 };
619 req.data.clear();
620
621 deserialize_response(self.send_request(&req)?)
622 }
623
624 pub fn bookmark(
630 &mut self,
631 user_id: &str,
632 post_id: &str,
633 opts: &CallOptions,
634 ) -> Result<ApiResponse<BookmarkedResult>> {
635 let post_id = resolve_post_id(post_id);
636 let mut req = opts.to_request_options();
637 req.method = "POST".to_string();
638 req.target = RequestTarget::Template {
639 path: "/2/users/{id}/bookmarks".to_string(),
640 path_params: HashMap::from([("id".to_string(), user_id.to_string())]),
641 query: Vec::new(),
642 };
643 req.data = format!(r#"{{"tweet_id":"{post_id}"}}"#);
644
645 deserialize_response(self.send_request(&req)?)
646 }
647
648 pub fn unbookmark(
654 &mut self,
655 user_id: &str,
656 post_id: &str,
657 opts: &CallOptions,
658 ) -> Result<ApiResponse<BookmarkedResult>> {
659 let post_id = resolve_post_id(post_id);
660 let mut req = opts.to_request_options();
661 req.method = "DELETE".to_string();
662 req.target = RequestTarget::Template {
663 path: "/2/users/{id}/bookmarks/{tweet_id}".to_string(),
664 path_params: HashMap::from([
665 ("id".to_string(), user_id.to_string()),
666 ("tweet_id".to_string(), post_id),
667 ]),
668 query: Vec::new(),
669 };
670 req.data.clear();
671
672 deserialize_response(self.send_request(&req)?)
673 }
674
675 pub fn get_bookmarks(
681 &mut self,
682 user_id: &str,
683 max_results: i32,
684 opts: &CallOptions,
685 ) -> Result<ApiResponse<Vec<Tweet>>> {
686 let mut q = vec![
687 ("max_results".to_string(), max_results.to_string()),
688 (
689 "tweet.fields".to_string(),
690 "created_at,public_metrics,entities".to_string(),
691 ),
692 ("expansions".to_string(), "author_id".to_string()),
693 ("user.fields".to_string(), "username,name".to_string()),
694 ];
695 q.extend(build_query(opts));
696
697 let mut req = opts.to_request_options();
698 req.method = "GET".to_string();
699 req.target = RequestTarget::Template {
700 path: "/2/users/{id}/bookmarks".to_string(),
701 path_params: HashMap::from([("id".to_string(), user_id.to_string())]),
702 query: q,
703 };
704 req.data.clear();
705
706 deserialize_response(self.send_request(&req)?)
707 }
708
709 pub fn follow_user(
715 &mut self,
716 source_user_id: &str,
717 target_user_id: &str,
718 opts: &CallOptions,
719 ) -> Result<ApiResponse<FollowingResult>> {
720 let mut req = opts.to_request_options();
721 req.method = "POST".to_string();
722 req.target = RequestTarget::Template {
723 path: "/2/users/{id}/following".to_string(),
724 path_params: HashMap::from([("id".to_string(), source_user_id.to_string())]),
725 query: Vec::new(),
726 };
727 req.data = format!(r#"{{"target_user_id":"{target_user_id}"}}"#);
728
729 deserialize_response(self.send_request(&req)?)
730 }
731
732 pub fn unfollow_user(
738 &mut self,
739 source_user_id: &str,
740 target_user_id: &str,
741 opts: &CallOptions,
742 ) -> Result<ApiResponse<FollowingResult>> {
743 let mut req = opts.to_request_options();
744 req.method = "DELETE".to_string();
745 req.target = RequestTarget::Template {
746 path: "/2/users/{source_user_id}/following/{target_user_id}".to_string(),
747 path_params: HashMap::from([
748 ("source_user_id".to_string(), source_user_id.to_string()),
749 ("target_user_id".to_string(), target_user_id.to_string()),
750 ]),
751 query: Vec::new(),
752 };
753 req.data.clear();
754
755 deserialize_response(self.send_request(&req)?)
756 }
757
758 pub fn get_following(
764 &mut self,
765 user_id: &str,
766 max_results: i32,
767 opts: &CallOptions,
768 ) -> Result<ApiResponse<Vec<User>>> {
769 let mut q = vec![
770 ("max_results".to_string(), max_results.to_string()),
771 (
772 "user.fields".to_string(),
773 "created_at,description,public_metrics,verified".to_string(),
774 ),
775 ];
776 q.extend(build_query(opts));
777
778 let mut req = opts.to_request_options();
779 req.method = "GET".to_string();
780 req.target = RequestTarget::Template {
781 path: "/2/users/{id}/following".to_string(),
782 path_params: HashMap::from([("id".to_string(), user_id.to_string())]),
783 query: q,
784 };
785 req.data.clear();
786
787 deserialize_response(self.send_request(&req)?)
788 }
789
790 pub fn get_followers(
796 &mut self,
797 user_id: &str,
798 max_results: i32,
799 opts: &CallOptions,
800 ) -> Result<ApiResponse<Vec<User>>> {
801 let mut q = vec![
802 ("max_results".to_string(), max_results.to_string()),
803 (
804 "user.fields".to_string(),
805 "created_at,description,public_metrics,verified".to_string(),
806 ),
807 ];
808 q.extend(build_query(opts));
809
810 let mut req = opts.to_request_options();
811 req.method = "GET".to_string();
812 req.target = RequestTarget::Template {
813 path: "/2/users/{id}/followers".to_string(),
814 path_params: HashMap::from([("id".to_string(), user_id.to_string())]),
815 query: q,
816 };
817 req.data.clear();
818
819 deserialize_response(self.send_request(&req)?)
820 }
821
822 pub fn send_dm(
828 &mut self,
829 participant_id: &str,
830 text: &str,
831 opts: &CallOptions,
832 ) -> Result<ApiResponse<DmEvent>> {
833 let body = serde_json::json!({"text": text});
834 let mut req = opts.to_request_options();
835 req.method = "POST".to_string();
836 req.target = RequestTarget::Template {
837 path: "/2/dm_conversations/with/{participant_id}/messages".to_string(),
838 path_params: HashMap::from([(
839 "participant_id".to_string(),
840 participant_id.to_string(),
841 )]),
842 query: Vec::new(),
843 };
844 req.data = serde_json::to_string(&body)?;
845
846 deserialize_response(self.send_request(&req)?)
847 }
848
849 pub fn get_dm_events(
855 &mut self,
856 max_results: i32,
857 opts: &CallOptions,
858 ) -> Result<ApiResponse<Vec<DmEvent>>> {
859 let mut q = vec![
860 ("max_results".to_string(), max_results.to_string()),
861 (
862 "dm_event.fields".to_string(),
863 "created_at,dm_conversation_id,sender_id,text".to_string(),
864 ),
865 ("expansions".to_string(), "sender_id".to_string()),
866 ("user.fields".to_string(), "username,name".to_string()),
867 ];
868 q.extend(build_query(opts));
869
870 let mut req = opts.to_request_options();
871 req.method = "GET".to_string();
872 req.target = RequestTarget::Template {
873 path: "/2/dm_events".to_string(),
874 path_params: HashMap::new(),
875 query: q,
876 };
877 req.data.clear();
878
879 deserialize_response(self.send_request(&req)?)
880 }
881
882 pub fn get_liked_posts(
888 &mut self,
889 user_id: &str,
890 max_results: i32,
891 opts: &CallOptions,
892 ) -> Result<ApiResponse<Vec<Tweet>>> {
893 let mut q = vec![
894 ("max_results".to_string(), max_results.to_string()),
895 (
896 "tweet.fields".to_string(),
897 "created_at,public_metrics,entities".to_string(),
898 ),
899 ("expansions".to_string(), "author_id".to_string()),
900 ("user.fields".to_string(), "username,name".to_string()),
901 ];
902 q.extend(build_query(opts));
903
904 let mut req = opts.to_request_options();
905 req.method = "GET".to_string();
906 req.target = RequestTarget::Template {
907 path: "/2/users/{id}/liked_tweets".to_string(),
908 path_params: HashMap::from([("id".to_string(), user_id.to_string())]),
909 query: q,
910 };
911 req.data.clear();
912
913 deserialize_response(self.send_request(&req)?)
914 }
915
916 pub fn mute_user(
922 &mut self,
923 source_user_id: &str,
924 target_user_id: &str,
925 opts: &CallOptions,
926 ) -> Result<ApiResponse<MutingResult>> {
927 let mut req = opts.to_request_options();
928 req.method = "POST".to_string();
929 req.target = RequestTarget::Template {
930 path: "/2/users/{id}/muting".to_string(),
931 path_params: HashMap::from([("id".to_string(), source_user_id.to_string())]),
932 query: Vec::new(),
933 };
934 req.data = format!(r#"{{"target_user_id":"{target_user_id}"}}"#);
935
936 deserialize_response(self.send_request(&req)?)
937 }
938
939 pub fn get_usage(&mut self, opts: &CallOptions) -> Result<ApiResponse<UsageData>> {
945 let mut req = opts.to_request_options();
946 req.method = "GET".to_string();
947 req.target = RequestTarget::Template {
948 path: "/2/usage/tweets".to_string(),
949 path_params: HashMap::new(),
950 query: vec![(
951 "usage.fields".to_string(),
952 "daily_project_usage,daily_client_app_usage".to_string(),
953 )],
954 };
955 req.data.clear();
956
957 deserialize_response(self.send_request(&req)?)
958 }
959
960 pub fn unmute_user(
966 &mut self,
967 source_user_id: &str,
968 target_user_id: &str,
969 opts: &CallOptions,
970 ) -> Result<ApiResponse<MutingResult>> {
971 let mut req = opts.to_request_options();
972 req.method = "DELETE".to_string();
973 req.target = RequestTarget::Template {
974 path: "/2/users/{source_user_id}/muting/{target_user_id}".to_string(),
975 path_params: HashMap::from([
976 ("source_user_id".to_string(), source_user_id.to_string()),
977 ("target_user_id".to_string(), target_user_id.to_string()),
978 ]),
979 query: Vec::new(),
980 };
981 req.data.clear();
982
983 deserialize_response(self.send_request(&req)?)
984 }
985}