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/// Side-effect controls for a post edit (`PUT /posts/{id}.json`). The default
19/// is an ordinary edit that bumps the topic and records a revision.
20#[derive(Debug, Clone, Copy, Default)]
21pub struct PostEditOptions {
22    /// Send `post[no_bump]=true` so the edit does not bump the topic to the
23    /// top of the category activity feed. For quiet maintenance edits.
24    pub no_bump: bool,
25    /// Send `post[skip_revision]=true` so the edit does not create a revision
26    /// (edit-history) entry. Suppresses the online audit trail; use sparingly.
27    pub skip_revision: bool,
28}
29
30/// Distilled row from /topics/private-messages-*.json.
31#[derive(Debug, Deserialize, Serialize, Clone)]
32pub struct PmTopicSummary {
33    pub id: u64,
34    #[serde(default)]
35    pub title: Option<String>,
36    #[serde(default)]
37    pub slug: Option<String>,
38    #[serde(default)]
39    pub posts_count: Option<u64>,
40    #[serde(default)]
41    pub last_posted_at: Option<String>,
42    #[serde(default)]
43    pub last_poster_username: Option<String>,
44    #[serde(default)]
45    pub unread: Option<u64>,
46}
47
48impl DiscourseClient {
49    /// Fetch a topic by ID.
50    pub fn fetch_topic(&self, topic_id: u64, include_raw: bool) -> Result<TopicResponse> {
51        let path = if include_raw {
52            format!("/t/{}.json?include_raw=1", topic_id)
53        } else {
54            format!("/t/{}.json", topic_id)
55        };
56        let response = self.get(&path)?;
57        let status = response.status();
58        let text = response.text().context("reading topic response body")?;
59        if !status.is_success() {
60            return Err(http_error("topic request", status, &text));
61        }
62        let body: TopicResponse = serde_json::from_str(&text).context("parsing topic json")?;
63        Ok(body)
64    }
65
66    /// Fetch every post in a topic, in order.
67    ///
68    /// Discourse paginates `/t/{id}.json` at 20 posts per page. The first
69    /// response also includes `post_stream.stream`, the flat array of every
70    /// post ID in the thread. We page-1 first to learn the stream, then
71    /// batch-fetch any remaining post IDs via
72    /// `/t/{id}/posts.json?post_ids[]=…&include_raw=1`. Returns posts in
73    /// stream order (matches topic display order).
74    pub fn fetch_topic_all_posts(&self, topic_id: u64) -> Result<TopicResponse> {
75        let mut topic = self.fetch_topic(topic_id, true)?;
76
77        // Build the set of IDs we already have from page 1.
78        let have: std::collections::HashSet<u64> =
79            topic.post_stream.posts.iter().map(|p| p.id).collect();
80        let missing: Vec<u64> = topic
81            .post_stream
82            .stream
83            .iter()
84            .copied()
85            .filter(|id| !have.contains(id))
86            .collect();
87
88        // Batch-fetch missing posts in chunks of 20 (Discourse's page size).
89        for chunk in missing.chunks(20) {
90            let query: Vec<String> = chunk
91                .iter()
92                .map(|id| format!("post_ids[]={}", id))
93                .collect();
94            let path = format!(
95                "/t/{}/posts.json?include_raw=1&{}",
96                topic_id,
97                query.join("&")
98            );
99            let response = self.get(&path)?;
100            let status = response.status();
101            let text = response.text().context("reading topic posts response body")?;
102            if !status.is_success() {
103                return Err(http_error("topic posts request", status, &text));
104            }
105            let body: TopicResponse = serde_json::from_str(&text)
106                .context("parsing topic posts response")?;
107            topic.post_stream.posts.extend(body.post_stream.posts);
108        }
109
110        // Reorder posts to match the canonical stream order.
111        if !topic.post_stream.stream.is_empty() {
112            let order: std::collections::HashMap<u64, usize> = topic
113                .post_stream
114                .stream
115                .iter()
116                .enumerate()
117                .map(|(i, id)| (*id, i))
118                .collect();
119            topic
120                .post_stream
121                .posts
122                .sort_by_key(|p| order.get(&p.id).copied().unwrap_or(usize::MAX));
123        }
124
125        Ok(topic)
126    }
127
128    /// Fetch a post by ID and return its raw content.
129    pub fn fetch_post_raw(&self, post_id: u64) -> Result<Option<String>> {
130        Ok(self.fetch_post(post_id)?.raw)
131    }
132
133    /// Fetch a post's metadata (id, topic_id, post_number, raw).
134    pub fn fetch_post(&self, post_id: u64) -> Result<PostInfo> {
135        let path = format!("/posts/{}.json?include_raw=1", post_id);
136        let response = self.get(&path)?;
137        let status = response.status();
138        let text = response.text().context("reading post response body")?;
139        if !status.is_success() {
140            return Err(http_error("post request", status, &text));
141        }
142        let info: PostInfo = serde_json::from_str(&text).context("parsing post response")?;
143        Ok(info)
144    }
145
146    /// Soft-delete a post by ID (DELETE /posts/:id.json).
147    pub fn delete_post(&self, post_id: u64) -> Result<()> {
148        let path = format!("/posts/{}.json", post_id);
149        let response = self.send_retrying(|| Ok(self.delete_builder(&path)?))?;
150        let status = response.status();
151        if !status.is_success() {
152            let text = response
153                .text()
154                .unwrap_or_else(|_| "<failed to read response body>".to_string());
155            return Err(http_error("delete post request", status, &text));
156        }
157        Ok(())
158    }
159
160    /// Move one or more posts from their current topic to another topic.
161    ///
162    /// `source_topic_id` is the topic the posts currently live in.
163    /// `post_ids` are the post IDs to move. `dest_topic_id` is where they land.
164    /// Returns the new URL of the moved posts' topic.
165    pub fn move_posts(
166        &self,
167        source_topic_id: u64,
168        post_ids: &[u64],
169        dest_topic_id: u64,
170    ) -> Result<String> {
171        if post_ids.is_empty() {
172            return Err(anyhow!("no post IDs supplied to move"));
173        }
174        let dest = dest_topic_id.to_string();
175        let path = format!("/t/{}/move-posts.json", source_topic_id);
176        let mut payload: Vec<(String, String)> = Vec::new();
177        payload.push(("destination_topic_id".to_string(), dest.clone()));
178        for id in post_ids {
179            payload.push(("post_ids[]".to_string(), id.to_string()));
180        }
181        let response = self.send_retrying(|| Ok(self.post(&path)?.form(&payload)))?;
182        let status = response.status();
183        let text = response.text().context("reading move-posts response")?;
184        if !status.is_success() {
185            return Err(http_error("move posts request", status, &text));
186        }
187        let value: Value =
188            serde_json::from_str(&text).context("parsing move-posts response")?;
189        let url = value
190            .get("url")
191            .and_then(|v| v.as_str())
192            .map(|s| s.to_string())
193            .unwrap_or_else(|| format!("/t/{}", dest));
194        Ok(url)
195    }
196
197    /// Update a post by ID. `opts` controls Discourse's edit side effects
198    /// (topic bump, revision history); [`PostEditOptions::default`] applies a
199    /// normal edit.
200    pub fn update_post(&self, post_id: u64, raw: &str, opts: PostEditOptions) -> Result<()> {
201        let path = format!("/posts/{}.json", post_id);
202        let payload = post_edit_payload(raw, opts);
203        let response = self.send_retrying(|| Ok(self.put(&path)?.form(&payload)))?;
204        let status = response.status();
205        if !status.is_success() {
206            let text = response
207                .text()
208                .unwrap_or_else(|_| "<failed to read response body>".to_string());
209            return Err(http_error("update post request", status, &text));
210        }
211        Ok(())
212    }
213
214    /// Create a new topic in a category.
215    pub fn create_topic(&self, category_id: u64, title: &str, raw: &str) -> Result<u64> {
216        let category = category_id.to_string();
217        let payload = [("title", title), ("raw", raw), ("category", &category)];
218        let response = self.send_retrying(|| Ok(self.post("/posts.json")?.form(&payload)))?;
219        let status = response.status();
220        let text = response.text().context("reading create response body")?;
221        if !status.is_success() {
222            return Err(http_error("create topic request", status, &text));
223        }
224        let body: CreatePostResponse =
225            serde_json::from_str(&text).context("parsing create topic response")?;
226        Ok(body.topic_id)
227    }
228
229    /// Send a private message. `recipients` is comma-joined into Discourse's
230    /// `target_recipients` field (usernames or group names accepted).
231    /// Returns the new topic_id of the PM thread.
232    pub fn create_private_message(
233        &self,
234        recipients: &[String],
235        title: &str,
236        raw: &str,
237    ) -> Result<u64> {
238        let recipients_csv = recipients.join(",");
239        let payload = [
240            ("title", title),
241            ("raw", raw),
242            ("archetype", "private_message"),
243            ("target_recipients", recipients_csv.as_str()),
244        ];
245        let response = self.send_retrying(|| Ok(self.post("/posts.json")?.form(&payload)))?;
246        let status = response.status();
247        let text = response.text().context("reading PM create response body")?;
248        if !status.is_success() {
249            return Err(http_error("create PM request", status, &text));
250        }
251        let body: CreatePostResponse =
252            serde_json::from_str(&text).context("parsing PM create response")?;
253        Ok(body.topic_id)
254    }
255
256    /// List private messages for the given user. `direction` is one of
257    /// `inbox` (received), `sent`, `archive`, `unread`, `new`. Returns
258    /// distilled topic summaries.
259    pub fn list_private_messages(
260        &self,
261        username: &str,
262        direction: &str,
263    ) -> Result<Vec<PmTopicSummary>> {
264        let path = match direction {
265            "inbox" => format!("/topics/private-messages/{}.json", username),
266            "sent" => format!("/topics/private-messages-sent/{}.json", username),
267            "archive" => format!("/topics/private-messages-archive/{}.json", username),
268            "unread" => format!("/topics/private-messages-unread/{}.json", username),
269            "new" => format!("/topics/private-messages-new/{}.json", username),
270            other => format!("/topics/private-messages-{}/{}.json", other, username),
271        };
272        let response = self.get(&path)?;
273        let status = response.status();
274        let text = response.text().context("reading PM list response")?;
275        if !status.is_success() {
276            return Err(http_error("PM list request", status, &text));
277        }
278        let value: Value = serde_json::from_str(&text).context("parsing PM list response")?;
279        let topics = value
280            .get("topic_list")
281            .and_then(|tl| tl.get("topics"))
282            .and_then(|t| t.as_array())
283            .map(|arr| {
284                arr.iter()
285                    .filter_map(|v| serde_json::from_value::<PmTopicSummary>(v.clone()).ok())
286                    .collect()
287            })
288            .unwrap_or_default();
289        Ok(topics)
290    }
291
292    /// Create a reply post in a topic.
293    pub fn create_post(&self, topic_id: u64, raw: &str) -> Result<u64> {
294        let topic = topic_id.to_string();
295        let payload = [("topic_id", topic.as_str()), ("raw", raw)];
296        let response = self.send_retrying(|| Ok(self.post("/posts.json")?.form(&payload)))?;
297        let status = response.status();
298        let text = response.text().context("reading create response body")?;
299        if !status.is_success() {
300            return Err(http_error("create post request", status, &text));
301        }
302        let body: CreatePostResponse =
303            serde_json::from_str(&text).context("parsing create post response")?;
304        Ok(body.id)
305    }
306}
307
308/// Build the urlencoded form payload for a `PUT /posts/{id}.json` edit.
309/// Always sends `post[raw]`; `post[no_bump]` / `post[skip_revision]` are added
310/// only when requested, so a default edit is byte-for-byte what `dsc` sent
311/// before these options existed.
312fn post_edit_payload(raw: &str, opts: PostEditOptions) -> Vec<(&'static str, &str)> {
313    let mut payload: Vec<(&'static str, &str)> = vec![("post[raw]", raw)];
314    if opts.no_bump {
315        payload.push(("post[no_bump]", "true"));
316    }
317    if opts.skip_revision {
318        payload.push(("post[skip_revision]", "true"));
319    }
320    payload
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn default_edit_sends_only_raw() {
329        let payload = post_edit_payload("hello", PostEditOptions::default());
330        assert_eq!(payload, vec![("post[raw]", "hello")]);
331    }
332
333    #[test]
334    fn no_bump_adds_form_field() {
335        let payload = post_edit_payload(
336            "hi",
337            PostEditOptions {
338                no_bump: true,
339                skip_revision: false,
340            },
341        );
342        assert_eq!(
343            payload,
344            vec![("post[raw]", "hi"), ("post[no_bump]", "true")]
345        );
346    }
347
348    #[test]
349    fn skip_revision_adds_form_field() {
350        let payload = post_edit_payload(
351            "hi",
352            PostEditOptions {
353                no_bump: false,
354                skip_revision: true,
355            },
356        );
357        assert_eq!(
358            payload,
359            vec![("post[raw]", "hi"), ("post[skip_revision]", "true")]
360        );
361    }
362
363    #[test]
364    fn both_flags_add_both_fields() {
365        let payload = post_edit_payload(
366            "x",
367            PostEditOptions {
368                no_bump: true,
369                skip_revision: true,
370            },
371        );
372        assert_eq!(
373            payload,
374            vec![
375                ("post[raw]", "x"),
376                ("post[no_bump]", "true"),
377                ("post[skip_revision]", "true"),
378            ]
379        );
380    }
381}