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 every post in a topic, in order.
55    ///
56    /// Discourse paginates `/t/{id}.json` at 20 posts per page. The first
57    /// response also includes `post_stream.stream`, the flat array of every
58    /// post ID in the thread. We page-1 first to learn the stream, then
59    /// batch-fetch any remaining post IDs via
60    /// `/t/{id}/posts.json?post_ids[]=…&include_raw=1`. Returns posts in
61    /// stream order (matches topic display order).
62    pub fn fetch_topic_all_posts(&self, topic_id: u64) -> Result<TopicResponse> {
63        let mut topic = self.fetch_topic(topic_id, true)?;
64
65        // Build the set of IDs we already have from page 1.
66        let have: std::collections::HashSet<u64> =
67            topic.post_stream.posts.iter().map(|p| p.id).collect();
68        let missing: Vec<u64> = topic
69            .post_stream
70            .stream
71            .iter()
72            .copied()
73            .filter(|id| !have.contains(id))
74            .collect();
75
76        // Batch-fetch missing posts in chunks of 20 (Discourse's page size).
77        for chunk in missing.chunks(20) {
78            let query: Vec<String> = chunk
79                .iter()
80                .map(|id| format!("post_ids[]={}", id))
81                .collect();
82            let path = format!(
83                "/t/{}/posts.json?include_raw=1&{}",
84                topic_id,
85                query.join("&")
86            );
87            let response = self.get(&path)?;
88            let status = response.status();
89            let text = response.text().context("reading topic posts response body")?;
90            if !status.is_success() {
91                return Err(http_error("topic posts request", status, &text));
92            }
93            let body: TopicResponse = serde_json::from_str(&text)
94                .context("parsing topic posts response")?;
95            topic.post_stream.posts.extend(body.post_stream.posts);
96        }
97
98        // Reorder posts to match the canonical stream order.
99        if !topic.post_stream.stream.is_empty() {
100            let order: std::collections::HashMap<u64, usize> = topic
101                .post_stream
102                .stream
103                .iter()
104                .enumerate()
105                .map(|(i, id)| (*id, i))
106                .collect();
107            topic
108                .post_stream
109                .posts
110                .sort_by_key(|p| order.get(&p.id).copied().unwrap_or(usize::MAX));
111        }
112
113        Ok(topic)
114    }
115
116    /// Fetch a post by ID and return its raw content.
117    pub fn fetch_post_raw(&self, post_id: u64) -> Result<Option<String>> {
118        Ok(self.fetch_post(post_id)?.raw)
119    }
120
121    /// Fetch a post's metadata (id, topic_id, post_number, raw).
122    pub fn fetch_post(&self, post_id: u64) -> Result<PostInfo> {
123        let path = format!("/posts/{}.json?include_raw=1", post_id);
124        let response = self.get(&path)?;
125        let status = response.status();
126        let text = response.text().context("reading post response body")?;
127        if !status.is_success() {
128            return Err(http_error("post request", status, &text));
129        }
130        let info: PostInfo = serde_json::from_str(&text).context("parsing post response")?;
131        Ok(info)
132    }
133
134    /// Soft-delete a post by ID (DELETE /posts/:id.json).
135    pub fn delete_post(&self, post_id: u64) -> Result<()> {
136        let path = format!("/posts/{}.json", post_id);
137        let response = self.send_retrying(|| Ok(self.delete_builder(&path)?))?;
138        let status = response.status();
139        if !status.is_success() {
140            let text = response
141                .text()
142                .unwrap_or_else(|_| "<failed to read response body>".to_string());
143            return Err(http_error("delete post request", status, &text));
144        }
145        Ok(())
146    }
147
148    /// Move one or more posts from their current topic to another topic.
149    ///
150    /// `source_topic_id` is the topic the posts currently live in.
151    /// `post_ids` are the post IDs to move. `dest_topic_id` is where they land.
152    /// Returns the new URL of the moved posts' topic.
153    pub fn move_posts(
154        &self,
155        source_topic_id: u64,
156        post_ids: &[u64],
157        dest_topic_id: u64,
158    ) -> Result<String> {
159        if post_ids.is_empty() {
160            return Err(anyhow!("no post IDs supplied to move"));
161        }
162        let dest = dest_topic_id.to_string();
163        let path = format!("/t/{}/move-posts.json", source_topic_id);
164        let mut payload: Vec<(String, String)> = Vec::new();
165        payload.push(("destination_topic_id".to_string(), dest.clone()));
166        for id in post_ids {
167            payload.push(("post_ids[]".to_string(), id.to_string()));
168        }
169        let response = self.send_retrying(|| Ok(self.post(&path)?.form(&payload)))?;
170        let status = response.status();
171        let text = response.text().context("reading move-posts response")?;
172        if !status.is_success() {
173            return Err(http_error("move posts request", status, &text));
174        }
175        let value: Value =
176            serde_json::from_str(&text).context("parsing move-posts response")?;
177        let url = value
178            .get("url")
179            .and_then(|v| v.as_str())
180            .map(|s| s.to_string())
181            .unwrap_or_else(|| format!("/t/{}", dest));
182        Ok(url)
183    }
184
185    /// Update a post by ID.
186    pub fn update_post(&self, post_id: u64, raw: &str) -> Result<()> {
187        let path = format!("/posts/{}.json", post_id);
188        let payload = [("post[raw]", raw)];
189        let response = self.send_retrying(|| Ok(self.put(&path)?.form(&payload)))?;
190        let status = response.status();
191        if !status.is_success() {
192            let text = response
193                .text()
194                .unwrap_or_else(|_| "<failed to read response body>".to_string());
195            return Err(http_error("update post request", status, &text));
196        }
197        Ok(())
198    }
199
200    /// Create a new topic in a category.
201    pub fn create_topic(&self, category_id: u64, title: &str, raw: &str) -> Result<u64> {
202        let category = category_id.to_string();
203        let payload = [("title", title), ("raw", raw), ("category", &category)];
204        let response = self.send_retrying(|| Ok(self.post("/posts.json")?.form(&payload)))?;
205        let status = response.status();
206        let text = response.text().context("reading create response body")?;
207        if !status.is_success() {
208            return Err(http_error("create topic request", status, &text));
209        }
210        let body: CreatePostResponse =
211            serde_json::from_str(&text).context("parsing create topic response")?;
212        Ok(body.topic_id)
213    }
214
215    /// Send a private message. `recipients` is comma-joined into Discourse's
216    /// `target_recipients` field (usernames or group names accepted).
217    /// Returns the new topic_id of the PM thread.
218    pub fn create_private_message(
219        &self,
220        recipients: &[String],
221        title: &str,
222        raw: &str,
223    ) -> Result<u64> {
224        let recipients_csv = recipients.join(",");
225        let payload = [
226            ("title", title),
227            ("raw", raw),
228            ("archetype", "private_message"),
229            ("target_recipients", recipients_csv.as_str()),
230        ];
231        let response = self.send_retrying(|| Ok(self.post("/posts.json")?.form(&payload)))?;
232        let status = response.status();
233        let text = response.text().context("reading PM create response body")?;
234        if !status.is_success() {
235            return Err(http_error("create PM request", status, &text));
236        }
237        let body: CreatePostResponse =
238            serde_json::from_str(&text).context("parsing PM create response")?;
239        Ok(body.topic_id)
240    }
241
242    /// List private messages for the given user. `direction` is one of
243    /// `inbox` (received), `sent`, `archive`, `unread`, `new`. Returns
244    /// distilled topic summaries.
245    pub fn list_private_messages(
246        &self,
247        username: &str,
248        direction: &str,
249    ) -> Result<Vec<PmTopicSummary>> {
250        let path = match direction {
251            "inbox" => format!("/topics/private-messages/{}.json", username),
252            "sent" => format!("/topics/private-messages-sent/{}.json", username),
253            "archive" => format!("/topics/private-messages-archive/{}.json", username),
254            "unread" => format!("/topics/private-messages-unread/{}.json", username),
255            "new" => format!("/topics/private-messages-new/{}.json", username),
256            other => format!("/topics/private-messages-{}/{}.json", other, username),
257        };
258        let response = self.get(&path)?;
259        let status = response.status();
260        let text = response.text().context("reading PM list response")?;
261        if !status.is_success() {
262            return Err(http_error("PM list request", status, &text));
263        }
264        let value: Value = serde_json::from_str(&text).context("parsing PM list response")?;
265        let topics = value
266            .get("topic_list")
267            .and_then(|tl| tl.get("topics"))
268            .and_then(|t| t.as_array())
269            .map(|arr| {
270                arr.iter()
271                    .filter_map(|v| serde_json::from_value::<PmTopicSummary>(v.clone()).ok())
272                    .collect()
273            })
274            .unwrap_or_default();
275        Ok(topics)
276    }
277
278    /// Create a reply post in a topic.
279    pub fn create_post(&self, topic_id: u64, raw: &str) -> Result<u64> {
280        let topic = topic_id.to_string();
281        let payload = [("topic_id", topic.as_str()), ("raw", raw)];
282        let response = self.send_retrying(|| Ok(self.post("/posts.json")?.form(&payload)))?;
283        let status = response.status();
284        let text = response.text().context("reading create response body")?;
285        if !status.is_success() {
286            return Err(http_error("create post request", status, &text));
287        }
288        let body: CreatePostResponse =
289            serde_json::from_str(&text).context("parsing create post response")?;
290        Ok(body.id)
291    }
292}