1use std::collections::HashMap;
2
3use crate::client::Client;
4use crate::constants;
5use crate::error;
6use crate::http::RequestBody;
7use crate::types::{
8 CarouselPostContent, ContainerId, ContainerStatus, ImagePostContent, Post, PostId,
9 TextPostContent, VideoPostContent,
10};
11use crate::validation;
12
13impl Client {
14 pub async fn create_text_post(&self, content: &TextPostContent) -> crate::Result<Post> {
17 let token = self.access_token().await;
18 if token.is_empty() {
19 return Err(error::new_authentication_error(
20 401,
21 "Access token is required",
22 "",
23 ));
24 }
25
26 let user_id = self.user_id().await;
27 if user_id.is_empty() {
28 return Err(error::new_authentication_error(
29 401,
30 constants::ERR_EMPTY_USER_ID,
31 "",
32 ));
33 }
34
35 if content.text.is_empty() {
36 return Err(error::new_validation_error(
37 0,
38 "Text is required for text posts",
39 "",
40 "text",
41 ));
42 }
43
44 validation::validate_text_length(&content.text, "text")?;
45 validation::validate_link_count(
46 &content.text,
47 content.link_attachment.as_deref().unwrap_or(""),
48 )?;
49 if let Some(ref entities) = content.text_entities {
50 validation::validate_text_entities(entities, content.text.chars().count())?;
51 }
52 if let Some(ref attachment) = content.text_attachment {
53 validation::validate_text_attachment(attachment)?;
54 }
55 if let Some(ref gif) = content.gif_attachment {
56 validation::validate_gif_attachment(gif)?;
57 }
58 if let Some(ref poll) = content.poll_attachment {
59 validation::validate_poll_attachment(poll)?;
60 }
61 if let Some(ref tag) = content.topic_tag {
62 validation::validate_topic_tag(tag)?;
63 }
64 if let Some(ref codes) = content.allowlisted_country_codes {
65 validation::validate_country_codes(codes)?;
66 }
67
68 if content.auto_publish_text {
69 let params = self.build_text_params(content, &user_id);
73 let container_id = self.create_container(params).await?;
74 let post_id = PostId::from(container_id.as_str());
75 return self.get_post(&post_id).await;
76 }
77
78 let params = self.build_text_params(content, &user_id);
80 let container_id = self.create_container(params).await?;
81 self.publish_container(&container_id).await
82 }
83
84 pub async fn create_image_post(&self, content: &ImagePostContent) -> crate::Result<Post> {
86 let token = self.access_token().await;
87 if token.is_empty() {
88 return Err(error::new_authentication_error(
89 401,
90 "Access token is required",
91 "",
92 ));
93 }
94
95 let user_id = self.user_id().await;
96 if user_id.is_empty() {
97 return Err(error::new_authentication_error(
98 401,
99 constants::ERR_EMPTY_USER_ID,
100 "",
101 ));
102 }
103
104 validation::validate_media_url(&content.image_url, "image")?;
105 if let Some(ref alt) = content.alt_text {
106 validation::validate_alt_text(alt)?;
107 }
108 if let Some(ref text) = content.text {
109 validation::validate_text_length(text, "text")?;
110 }
111 if let Some(ref entities) = content.text_entities {
112 let text_len = content.text.as_deref().map_or(0, |t| t.chars().count());
113 validation::validate_text_entities(entities, text_len)?;
114 }
115 if let Some(ref tag) = content.topic_tag {
116 validation::validate_topic_tag(tag)?;
117 }
118 if let Some(ref codes) = content.allowlisted_country_codes {
119 validation::validate_country_codes(codes)?;
120 }
121
122 let params = self.build_image_params(content, &user_id);
123 let container_id = self.create_container(params).await?;
124 let cid = ContainerId::from(container_id.as_str());
125 self.wait_for_container_ready(&cid).await?;
126 self.publish_container(&container_id).await
127 }
128
129 pub async fn create_video_post(&self, content: &VideoPostContent) -> crate::Result<Post> {
131 let token = self.access_token().await;
132 if token.is_empty() {
133 return Err(error::new_authentication_error(
134 401,
135 "Access token is required",
136 "",
137 ));
138 }
139
140 let user_id = self.user_id().await;
141 if user_id.is_empty() {
142 return Err(error::new_authentication_error(
143 401,
144 constants::ERR_EMPTY_USER_ID,
145 "",
146 ));
147 }
148
149 validation::validate_media_url(&content.video_url, "video")?;
150 if let Some(ref alt) = content.alt_text {
151 validation::validate_alt_text(alt)?;
152 }
153 if let Some(ref text) = content.text {
154 validation::validate_text_length(text, "text")?;
155 }
156 if let Some(ref entities) = content.text_entities {
157 let text_len = content.text.as_deref().map_or(0, |t| t.chars().count());
158 validation::validate_text_entities(entities, text_len)?;
159 }
160 if let Some(ref tag) = content.topic_tag {
161 validation::validate_topic_tag(tag)?;
162 }
163 if let Some(ref codes) = content.allowlisted_country_codes {
164 validation::validate_country_codes(codes)?;
165 }
166
167 let params = self.build_video_params(content, &user_id);
168 let container_id = self.create_container(params).await?;
169 let cid = ContainerId::from(container_id.as_str());
170 self.wait_for_container_ready(&cid).await?;
171 self.publish_container(&container_id).await
172 }
173
174 pub async fn create_carousel_post(&self, content: &CarouselPostContent) -> crate::Result<Post> {
176 let token = self.access_token().await;
177 if token.is_empty() {
178 return Err(error::new_authentication_error(
179 401,
180 "Access token is required",
181 "",
182 ));
183 }
184
185 let user_id = self.user_id().await;
186 if user_id.is_empty() {
187 return Err(error::new_authentication_error(
188 401,
189 constants::ERR_EMPTY_USER_ID,
190 "",
191 ));
192 }
193
194 validation::validate_carousel_children(content.children.len())?;
195 if let Some(ref text) = content.text {
196 validation::validate_text_length(text, "text")?;
197 }
198 if let Some(ref entities) = content.text_entities {
199 let text_len = content.text.as_deref().map_or(0, |t| t.chars().count());
200 validation::validate_text_entities(entities, text_len)?;
201 }
202 if let Some(ref tag) = content.topic_tag {
203 validation::validate_topic_tag(tag)?;
204 }
205 if let Some(ref codes) = content.allowlisted_country_codes {
206 validation::validate_country_codes(codes)?;
207 }
208
209 let params = self.build_carousel_params(content, &user_id);
210 let container_id = self.create_container(params).await?;
211 let cid = ContainerId::from(container_id.as_str());
212 self.wait_for_container_ready(&cid).await?;
213 self.publish_container(&container_id).await
214 }
215
216 pub async fn create_quote_post(
218 &self,
219 text: &str,
220 quoted_post_id: &PostId,
221 ) -> crate::Result<Post> {
222 let content = TextPostContent {
223 text: text.to_owned(),
224 quoted_post_id: Some(quoted_post_id.clone()),
225 link_attachment: None,
226 poll_attachment: None,
227 reply_control: None,
228 reply_to_id: None,
229 topic_tag: None,
230 allowlisted_country_codes: None,
231 location_id: None,
232 auto_publish_text: false,
233 text_entities: None,
234 text_attachment: None,
235 gif_attachment: None,
236 is_ghost_post: false,
237 enable_reply_approvals: false,
238 };
239 self.create_text_post(&content).await
240 }
241
242 pub async fn create_media_container(
252 &self,
253 media_type: &str,
254 media_url: &str,
255 alt_text: Option<&str>,
256 ) -> crate::Result<ContainerId> {
257 let token = self.access_token().await;
258 if token.is_empty() {
259 return Err(error::new_authentication_error(
260 401,
261 "Access token is required",
262 "",
263 ));
264 }
265
266 let user_id = self.user_id().await;
267 if user_id.is_empty() {
268 return Err(error::new_authentication_error(
269 401,
270 constants::ERR_EMPTY_USER_ID,
271 "",
272 ));
273 }
274
275 let mut params = HashMap::new();
276 params.insert("media_type".into(), media_type.to_owned());
277 params.insert("is_carousel_item".into(), "true".into());
278
279 let url_key = match media_type {
280 "VIDEO" => "video_url",
281 _ => "image_url",
282 };
283 params.insert(url_key.into(), media_url.to_owned());
284
285 if let Some(alt) = alt_text {
286 params.insert("alt_text".into(), alt.to_owned());
287 }
288
289 let id = self.create_container(params).await?;
290 Ok(ContainerId::from(id.as_str()))
291 }
292
293 pub async fn repost_post(&self, post_id: &PostId) -> crate::Result<PostId> {
299 if !post_id.is_valid() {
300 return Err(error::new_validation_error(
301 0,
302 constants::ERR_EMPTY_POST_ID,
303 "",
304 "post_id",
305 ));
306 }
307
308 let token = self.access_token().await;
309 if token.is_empty() {
310 return Err(error::new_authentication_error(
311 401,
312 "Access token is required",
313 "",
314 ));
315 }
316
317 let path = format!("/{}/repost", post_id);
318 let resp = self.http_client.post(&path, None, &token).await?;
319
320 #[derive(serde::Deserialize)]
321 struct RepostResponse {
322 id: String,
323 }
324
325 let repost_resp: RepostResponse = resp.json()?;
326 Ok(PostId::from(repost_resp.id.as_str()))
327 }
328
329 pub async fn get_container_status(
331 &self,
332 container_id: &ContainerId,
333 ) -> crate::Result<ContainerStatus> {
334 if !container_id.is_valid() {
335 return Err(error::new_validation_error(
336 0,
337 constants::ERR_EMPTY_CONTAINER_ID,
338 "",
339 "container_id",
340 ));
341 }
342
343 let token = self.access_token().await;
344 let mut params = HashMap::new();
345 params.insert("fields".into(), constants::CONTAINER_STATUS_FIELDS.into());
346
347 let path = format!("/{}", container_id);
348 let resp = self.http_client.get(&path, params, &token).await?;
349 resp.json()
350 }
351
352 async fn create_container(&self, params: HashMap<String, String>) -> crate::Result<String> {
356 let token = self.access_token().await;
357 let user_id = self.user_id().await;
358 let path = format!("/{}/threads", user_id);
359 let body = RequestBody::Form(params);
360 let resp = self.http_client.post(&path, Some(body), &token).await?;
361
362 #[derive(serde::Deserialize)]
363 struct ContainerResponse {
364 id: String,
365 }
366
367 let container: ContainerResponse = resp.json()?;
368 Ok(container.id)
369 }
370
371 async fn publish_container(&self, container_id: &str) -> crate::Result<Post> {
373 let token = self.access_token().await;
374 let user_id = self.user_id().await;
375 let path = format!("/{}/threads_publish", user_id);
376
377 let mut params = HashMap::new();
378 params.insert("creation_id".into(), container_id.to_owned());
379
380 let body = RequestBody::Form(params);
381 let resp = self.http_client.post(&path, Some(body), &token).await?;
382 resp.json()
383 }
384
385 async fn wait_for_container_ready(&self, container_id: &ContainerId) -> crate::Result<()> {
387 for attempt in 0..constants::DEFAULT_CONTAINER_POLL_MAX_ATTEMPTS {
388 let status = self.get_container_status(container_id).await?;
389
390 if status.status == constants::CONTAINER_STATUS_FINISHED
391 || status.status == constants::CONTAINER_STATUS_PUBLISHED
392 {
393 return Ok(());
394 }
395
396 if status.status == constants::CONTAINER_STATUS_ERROR {
397 let msg = status
398 .error_message
399 .unwrap_or_else(|| "Container processing failed".into());
400 return Err(error::new_api_error(0, &msg, "", ""));
401 }
402
403 if status.status == constants::CONTAINER_STATUS_EXPIRED {
404 return Err(error::new_api_error(
405 0,
406 "Container expired before publishing",
407 "",
408 "",
409 ));
410 }
411
412 if attempt < constants::DEFAULT_CONTAINER_POLL_MAX_ATTEMPTS - 1 {
414 tokio::time::sleep(constants::DEFAULT_CONTAINER_POLL_INTERVAL).await;
415 }
416 }
417
418 Err(error::new_api_error(
419 0,
420 "Container status polling timed out",
421 &format!(
422 "Container {} did not reach FINISHED after {} attempts",
423 container_id,
424 constants::DEFAULT_CONTAINER_POLL_MAX_ATTEMPTS
425 ),
426 "",
427 ))
428 }
429
430 fn build_text_params(
431 &self,
432 content: &TextPostContent,
433 _user_id: &str,
434 ) -> HashMap<String, String> {
435 let mut params = HashMap::new();
436 params.insert("media_type".into(), constants::MEDIA_TYPE_TEXT.into());
437 params.insert("text".into(), content.text.clone());
438
439 if let Some(ref link) = content.link_attachment {
440 params.insert("link_attachment".into(), link.clone());
441 }
442 if let Some(ref rc) = content.reply_control {
443 params.insert(
444 "reply_control".into(),
445 serde_json::to_string(rc)
446 .unwrap_or_default()
447 .trim_matches('"')
448 .to_owned(),
449 );
450 }
451 if let Some(ref reply_to) = content.reply_to_id {
452 params.insert("reply_to_id".into(), reply_to.to_string());
453 }
454 if let Some(ref topic) = content.topic_tag {
455 params.insert("topic_tag".into(), topic.clone());
456 }
457 if let Some(ref codes) = content.allowlisted_country_codes {
458 if !codes.is_empty() {
459 params.insert("allowlisted_country_codes".into(), codes.join(","));
460 }
461 }
462 if let Some(ref loc) = content.location_id {
463 params.insert("location_id".into(), loc.clone());
464 }
465 if let Some(ref qp) = content.quoted_post_id {
466 params.insert("quote_post_id".into(), qp.to_string());
467 }
468 if content.auto_publish_text {
469 params.insert("auto_publish_text".into(), "true".into());
470 }
471 if content.is_ghost_post {
472 params.insert("is_ghost_post".into(), "true".into());
473 }
474 if content.enable_reply_approvals {
475 params.insert("enable_reply_approvals".into(), "true".into());
476 }
477 if let Some(ref entities) = content.text_entities {
478 if let Ok(json) = serde_json::to_string(entities) {
479 params.insert("text_entities".into(), json);
480 }
481 }
482 if let Some(ref attachment) = content.text_attachment {
483 if let Ok(json) = serde_json::to_string(attachment) {
484 params.insert("text_attachment".into(), json);
485 }
486 }
487 if let Some(ref gif) = content.gif_attachment {
488 if let Ok(json) = serde_json::to_string(gif) {
489 params.insert("gif_attachment".into(), json);
490 }
491 }
492 if let Some(ref poll) = content.poll_attachment {
493 if let Ok(json) = serde_json::to_string(poll) {
494 params.insert("poll_attachment".into(), json);
495 }
496 }
497
498 params
499 }
500
501 fn build_image_params(
502 &self,
503 content: &ImagePostContent,
504 _user_id: &str,
505 ) -> HashMap<String, String> {
506 let mut params = HashMap::new();
507 params.insert("media_type".into(), constants::MEDIA_TYPE_IMAGE.into());
508 params.insert("image_url".into(), content.image_url.clone());
509
510 if let Some(ref text) = content.text {
511 params.insert("text".into(), text.clone());
512 }
513 if let Some(ref alt) = content.alt_text {
514 params.insert("alt_text".into(), alt.clone());
515 }
516 if let Some(ref rc) = content.reply_control {
517 params.insert(
518 "reply_control".into(),
519 serde_json::to_string(rc)
520 .unwrap_or_default()
521 .trim_matches('"')
522 .to_owned(),
523 );
524 }
525 if let Some(ref reply_to) = content.reply_to_id {
526 params.insert("reply_to_id".into(), reply_to.to_string());
527 }
528 if let Some(ref topic) = content.topic_tag {
529 params.insert("topic_tag".into(), topic.clone());
530 }
531 if let Some(ref codes) = content.allowlisted_country_codes {
532 if !codes.is_empty() {
533 params.insert("allowlisted_country_codes".into(), codes.join(","));
534 }
535 }
536 if let Some(ref loc) = content.location_id {
537 params.insert("location_id".into(), loc.clone());
538 }
539 if let Some(ref qp) = content.quoted_post_id {
540 params.insert("quote_post_id".into(), qp.to_string());
541 }
542 if content.is_spoiler_media {
543 params.insert("is_spoiler_media".into(), "true".into());
544 }
545 if content.enable_reply_approvals {
546 params.insert("enable_reply_approvals".into(), "true".into());
547 }
548 if let Some(ref entities) = content.text_entities {
549 if let Ok(json) = serde_json::to_string(entities) {
550 params.insert("text_entities".into(), json);
551 }
552 }
553
554 params
555 }
556
557 fn build_video_params(
558 &self,
559 content: &VideoPostContent,
560 _user_id: &str,
561 ) -> HashMap<String, String> {
562 let mut params = HashMap::new();
563 params.insert("media_type".into(), constants::MEDIA_TYPE_VIDEO.into());
564 params.insert("video_url".into(), content.video_url.clone());
565
566 if let Some(ref text) = content.text {
567 params.insert("text".into(), text.clone());
568 }
569 if let Some(ref alt) = content.alt_text {
570 params.insert("alt_text".into(), alt.clone());
571 }
572 if let Some(ref rc) = content.reply_control {
573 params.insert(
574 "reply_control".into(),
575 serde_json::to_string(rc)
576 .unwrap_or_default()
577 .trim_matches('"')
578 .to_owned(),
579 );
580 }
581 if let Some(ref reply_to) = content.reply_to_id {
582 params.insert("reply_to_id".into(), reply_to.to_string());
583 }
584 if let Some(ref topic) = content.topic_tag {
585 params.insert("topic_tag".into(), topic.clone());
586 }
587 if let Some(ref codes) = content.allowlisted_country_codes {
588 if !codes.is_empty() {
589 params.insert("allowlisted_country_codes".into(), codes.join(","));
590 }
591 }
592 if let Some(ref loc) = content.location_id {
593 params.insert("location_id".into(), loc.clone());
594 }
595 if let Some(ref qp) = content.quoted_post_id {
596 params.insert("quote_post_id".into(), qp.to_string());
597 }
598 if content.is_spoiler_media {
599 params.insert("is_spoiler_media".into(), "true".into());
600 }
601 if content.enable_reply_approvals {
602 params.insert("enable_reply_approvals".into(), "true".into());
603 }
604 if let Some(ref entities) = content.text_entities {
605 if let Ok(json) = serde_json::to_string(entities) {
606 params.insert("text_entities".into(), json);
607 }
608 }
609
610 params
611 }
612
613 fn build_carousel_params(
614 &self,
615 content: &CarouselPostContent,
616 _user_id: &str,
617 ) -> HashMap<String, String> {
618 let mut params = HashMap::new();
619 params.insert("media_type".into(), constants::MEDIA_TYPE_CAROUSEL.into());
620 let children_str: Vec<String> = content.children.iter().map(|c| c.to_string()).collect();
621 params.insert("children".into(), children_str.join(","));
622
623 if let Some(ref text) = content.text {
624 params.insert("text".into(), text.clone());
625 }
626 if let Some(ref rc) = content.reply_control {
627 params.insert(
628 "reply_control".into(),
629 serde_json::to_string(rc)
630 .unwrap_or_default()
631 .trim_matches('"')
632 .to_owned(),
633 );
634 }
635 if let Some(ref reply_to) = content.reply_to_id {
636 params.insert("reply_to_id".into(), reply_to.to_string());
637 }
638 if let Some(ref topic) = content.topic_tag {
639 params.insert("topic_tag".into(), topic.clone());
640 }
641 if let Some(ref codes) = content.allowlisted_country_codes {
642 if !codes.is_empty() {
643 params.insert("allowlisted_country_codes".into(), codes.join(","));
644 }
645 }
646 if let Some(ref loc) = content.location_id {
647 params.insert("location_id".into(), loc.clone());
648 }
649 if let Some(ref qp) = content.quoted_post_id {
650 params.insert("quote_post_id".into(), qp.to_string());
651 }
652 if content.is_spoiler_media {
653 params.insert("is_spoiler_media".into(), "true".into());
654 }
655 if content.enable_reply_approvals {
656 params.insert("enable_reply_approvals".into(), "true".into());
657 }
658 if let Some(ref entities) = content.text_entities {
659 if let Ok(json) = serde_json::to_string(entities) {
660 params.insert("text_entities".into(), json);
661 }
662 }
663
664 params
665 }
666}