Skip to main content

dsc/api/
topics.rs

1use super::client::DiscourseClient;
2use super::error::http_error;
3use super::models::{CreatePostResponse, TopicResponse};
4use anyhow::{Context, Result, anyhow};
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8#[derive(Debug, Deserialize, Serialize, Clone)]
9pub struct PostInfo {
10    pub id: u64,
11    pub topic_id: u64,
12    #[serde(default)]
13    pub post_number: Option<u64>,
14    #[serde(default)]
15    pub raw: Option<String>,
16}
17
18/// Distilled row from /topics/private-messages-*.json.
19#[derive(Debug, Deserialize, Serialize, Clone)]
20pub struct PmTopicSummary {
21    pub id: u64,
22    #[serde(default)]
23    pub title: Option<String>,
24    #[serde(default)]
25    pub slug: Option<String>,
26    #[serde(default)]
27    pub posts_count: Option<u64>,
28    #[serde(default)]
29    pub last_posted_at: Option<String>,
30    #[serde(default)]
31    pub last_poster_username: Option<String>,
32    #[serde(default)]
33    pub unread: Option<u64>,
34}
35
36impl DiscourseClient {
37    /// Fetch a topic by ID.
38    pub fn fetch_topic(&self, topic_id: u64, include_raw: bool) -> Result<TopicResponse> {
39        let path = if include_raw {
40            format!("/t/{}.json?include_raw=1", topic_id)
41        } else {
42            format!("/t/{}.json", topic_id)
43        };
44        let response = self.get(&path)?;
45        let status = response.status();
46        let text = response.text().context("reading topic response body")?;
47        if !status.is_success() {
48            return Err(http_error("topic request", status, &text));
49        }
50        let body: TopicResponse = serde_json::from_str(&text).context("parsing topic json")?;
51        Ok(body)
52    }
53
54    /// Fetch a post by ID and return its raw content.
55    pub fn fetch_post_raw(&self, post_id: u64) -> Result<Option<String>> {
56        Ok(self.fetch_post(post_id)?.raw)
57    }
58
59    /// Fetch a post's metadata (id, topic_id, post_number, raw).
60    pub fn fetch_post(&self, post_id: u64) -> Result<PostInfo> {
61        let path = format!("/posts/{}.json?include_raw=1", post_id);
62        let response = self.get(&path)?;
63        let status = response.status();
64        let text = response.text().context("reading post response body")?;
65        if !status.is_success() {
66            return Err(http_error("post request", status, &text));
67        }
68        let info: PostInfo = serde_json::from_str(&text).context("parsing post response")?;
69        Ok(info)
70    }
71
72    /// Soft-delete a post by ID (DELETE /posts/:id.json).
73    pub fn delete_post(&self, post_id: u64) -> Result<()> {
74        let path = format!("/posts/{}.json", post_id);
75        let response = self.send_retrying(|| Ok(self.delete_builder(&path)?))?;
76        let status = response.status();
77        if !status.is_success() {
78            let text = response
79                .text()
80                .unwrap_or_else(|_| "<failed to read response body>".to_string());
81            return Err(http_error("delete post request", status, &text));
82        }
83        Ok(())
84    }
85
86    /// Move one or more posts from their current topic to another topic.
87    ///
88    /// `source_topic_id` is the topic the posts currently live in.
89    /// `post_ids` are the post IDs to move. `dest_topic_id` is where they land.
90    /// Returns the new URL of the moved posts' topic.
91    pub fn move_posts(
92        &self,
93        source_topic_id: u64,
94        post_ids: &[u64],
95        dest_topic_id: u64,
96    ) -> Result<String> {
97        if post_ids.is_empty() {
98            return Err(anyhow!("no post IDs supplied to move"));
99        }
100        let dest = dest_topic_id.to_string();
101        let path = format!("/t/{}/move-posts.json", source_topic_id);
102        let mut payload: Vec<(String, String)> = Vec::new();
103        payload.push(("destination_topic_id".to_string(), dest.clone()));
104        for id in post_ids {
105            payload.push(("post_ids[]".to_string(), id.to_string()));
106        }
107        let response = self.send_retrying(|| Ok(self.post(&path)?.form(&payload)))?;
108        let status = response.status();
109        let text = response.text().context("reading move-posts response")?;
110        if !status.is_success() {
111            return Err(http_error("move posts request", status, &text));
112        }
113        let value: Value =
114            serde_json::from_str(&text).context("parsing move-posts response")?;
115        let url = value
116            .get("url")
117            .and_then(|v| v.as_str())
118            .map(|s| s.to_string())
119            .unwrap_or_else(|| format!("/t/{}", dest));
120        Ok(url)
121    }
122
123    /// Update a post by ID.
124    pub fn update_post(&self, post_id: u64, raw: &str) -> Result<()> {
125        let path = format!("/posts/{}.json", post_id);
126        let payload = [("post[raw]", raw)];
127        let response = self.send_retrying(|| Ok(self.put(&path)?.form(&payload)))?;
128        let status = response.status();
129        if !status.is_success() {
130            let text = response
131                .text()
132                .unwrap_or_else(|_| "<failed to read response body>".to_string());
133            return Err(http_error("update post request", status, &text));
134        }
135        Ok(())
136    }
137
138    /// Create a new topic in a category.
139    pub fn create_topic(&self, category_id: u64, title: &str, raw: &str) -> Result<u64> {
140        let category = category_id.to_string();
141        let payload = [("title", title), ("raw", raw), ("category", &category)];
142        let response = self.send_retrying(|| Ok(self.post("/posts.json")?.form(&payload)))?;
143        let status = response.status();
144        let text = response.text().context("reading create response body")?;
145        if !status.is_success() {
146            return Err(http_error("create topic request", status, &text));
147        }
148        let body: CreatePostResponse =
149            serde_json::from_str(&text).context("parsing create topic response")?;
150        Ok(body.topic_id)
151    }
152
153    /// Send a private message. `recipients` is comma-joined into Discourse's
154    /// `target_recipients` field (usernames or group names accepted).
155    /// Returns the new topic_id of the PM thread.
156    pub fn create_private_message(
157        &self,
158        recipients: &[String],
159        title: &str,
160        raw: &str,
161    ) -> Result<u64> {
162        let recipients_csv = recipients.join(",");
163        let payload = [
164            ("title", title),
165            ("raw", raw),
166            ("archetype", "private_message"),
167            ("target_recipients", recipients_csv.as_str()),
168        ];
169        let response = self.send_retrying(|| Ok(self.post("/posts.json")?.form(&payload)))?;
170        let status = response.status();
171        let text = response.text().context("reading PM create response body")?;
172        if !status.is_success() {
173            return Err(http_error("create PM request", status, &text));
174        }
175        let body: CreatePostResponse =
176            serde_json::from_str(&text).context("parsing PM create response")?;
177        Ok(body.topic_id)
178    }
179
180    /// List private messages for the given user. `direction` is one of
181    /// `inbox` (received), `sent`, `archive`, `unread`, `new`. Returns
182    /// distilled topic summaries.
183    pub fn list_private_messages(
184        &self,
185        username: &str,
186        direction: &str,
187    ) -> Result<Vec<PmTopicSummary>> {
188        let path = match direction {
189            "inbox" => format!("/topics/private-messages/{}.json", username),
190            "sent" => format!("/topics/private-messages-sent/{}.json", username),
191            "archive" => format!("/topics/private-messages-archive/{}.json", username),
192            "unread" => format!("/topics/private-messages-unread/{}.json", username),
193            "new" => format!("/topics/private-messages-new/{}.json", username),
194            other => format!("/topics/private-messages-{}/{}.json", other, username),
195        };
196        let response = self.get(&path)?;
197        let status = response.status();
198        let text = response.text().context("reading PM list response")?;
199        if !status.is_success() {
200            return Err(http_error("PM list request", status, &text));
201        }
202        let value: Value = serde_json::from_str(&text).context("parsing PM list response")?;
203        let topics = value
204            .get("topic_list")
205            .and_then(|tl| tl.get("topics"))
206            .and_then(|t| t.as_array())
207            .map(|arr| {
208                arr.iter()
209                    .filter_map(|v| serde_json::from_value::<PmTopicSummary>(v.clone()).ok())
210                    .collect()
211            })
212            .unwrap_or_default();
213        Ok(topics)
214    }
215
216    /// Create a reply post in a topic.
217    pub fn create_post(&self, topic_id: u64, raw: &str) -> Result<u64> {
218        let topic = topic_id.to_string();
219        let payload = [("topic_id", topic.as_str()), ("raw", raw)];
220        let response = self.send_retrying(|| Ok(self.post("/posts.json")?.form(&payload)))?;
221        let status = response.status();
222        let text = response.text().context("reading create response body")?;
223        if !status.is_success() {
224            return Err(http_error("create post request", status, &text));
225        }
226        let body: CreatePostResponse =
227            serde_json::from_str(&text).context("parsing create post response")?;
228        Ok(body.id)
229    }
230}