Skip to main content

threads_rs/api/
posts.rs

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    /// Create a text post. If `auto_publish_text` is true, skips the
15    /// container+publish flow and posts directly.
16    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            // auto_publish_text: API publishes during container creation — no
70            // separate publish call needed. The container creation response
71            // already contains the media ID.
72            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        // Text containers are ready immediately — skip polling
79        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    /// Create an image post.
85    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    /// Create a video post.
130    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    /// Create a carousel post.
175    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    /// Create a quote post — a text post that quotes another post.
217    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    /// Create a media container and return its ID.
243    ///
244    /// Public wrapper around the internal container creation. Useful for
245    /// building carousel children or custom publishing flows.
246    ///
247    /// **Note:** The container is returned immediately after creation. The
248    /// caller must poll [`get_container_status`](Self::get_container_status)
249    /// until the status is `FINISHED` before using the container in a
250    /// publish call (e.g. `create_carousel_post`).
251    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    /// Repost an existing post and return the repost's post ID.
294    ///
295    /// Unlike the previous implementation, this does **not** make an extra
296    /// API call to fetch the full post. Call `get_post` on the returned ID
297    /// if you need the full post details.
298    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    /// Get the status of a media container.
330    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    // ---- Private helpers ----
353
354    /// Create a media container and return its ID.
355    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    /// Publish a media container and return the resulting post.
372    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    /// Poll container status until it is FINISHED/PUBLISHED, ERROR, or EXPIRED.
386    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            // Don't sleep after the last attempt
413            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}