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
102                .text()
103                .context("reading topic posts response body")?;
104            if !status.is_success() {
105                return Err(http_error("topic posts request", status, &text));
106            }
107            let body: TopicResponse =
108                serde_json::from_str(&text).context("parsing topic posts response")?;
109            topic.post_stream.posts.extend(body.post_stream.posts);
110        }
111
112        // Reorder posts to match the canonical stream order.
113        if !topic.post_stream.stream.is_empty() {
114            let order: std::collections::HashMap<u64, usize> = topic
115                .post_stream
116                .stream
117                .iter()
118                .enumerate()
119                .map(|(i, id)| (*id, i))
120                .collect();
121            topic
122                .post_stream
123                .posts
124                .sort_by_key(|p| order.get(&p.id).copied().unwrap_or(usize::MAX));
125        }
126
127        Ok(topic)
128    }
129
130    /// Fetch a post by ID and return its raw content.
131    pub fn fetch_post_raw(&self, post_id: u64) -> Result<Option<String>> {
132        Ok(self.fetch_post(post_id)?.raw)
133    }
134
135    /// Fetch a post's metadata (id, topic_id, post_number, raw).
136    pub fn fetch_post(&self, post_id: u64) -> Result<PostInfo> {
137        let path = format!("/posts/{}.json?include_raw=1", post_id);
138        let response = self.get(&path)?;
139        let status = response.status();
140        let text = response.text().context("reading post response body")?;
141        if !status.is_success() {
142            return Err(http_error("post request", status, &text));
143        }
144        let info: PostInfo = serde_json::from_str(&text).context("parsing post response")?;
145        Ok(info)
146    }
147
148    /// Soft-delete a post by ID (DELETE /posts/:id.json).
149    pub fn delete_post(&self, post_id: u64) -> Result<()> {
150        let path = format!("/posts/{}.json", post_id);
151        let response = self.send_retrying(|| self.delete_builder(&path))?;
152        let status = response.status();
153        if !status.is_success() {
154            let text = response
155                .text()
156                .unwrap_or_else(|_| "<failed to read response body>".to_string());
157            return Err(http_error("delete post request", status, &text));
158        }
159        Ok(())
160    }
161
162    /// Move one or more posts from their current topic to another topic.
163    ///
164    /// `source_topic_id` is the topic the posts currently live in.
165    /// `post_ids` are the post IDs to move. `dest_topic_id` is where they land.
166    /// Returns the new URL of the moved posts' topic.
167    pub fn move_posts(
168        &self,
169        source_topic_id: u64,
170        post_ids: &[u64],
171        dest_topic_id: u64,
172    ) -> Result<String> {
173        if post_ids.is_empty() {
174            return Err(anyhow!("no post IDs supplied to move"));
175        }
176        let dest = dest_topic_id.to_string();
177        let path = format!("/t/{}/move-posts.json", source_topic_id);
178        let mut payload: Vec<(String, String)> = Vec::new();
179        payload.push(("destination_topic_id".to_string(), dest.clone()));
180        for id in post_ids {
181            payload.push(("post_ids[]".to_string(), id.to_string()));
182        }
183        let response = self.send_retrying(|| Ok(self.post(&path)?.form(&payload)))?;
184        let status = response.status();
185        let text = response.text().context("reading move-posts response")?;
186        if !status.is_success() {
187            return Err(http_error("move posts request", status, &text));
188        }
189        let value: Value = serde_json::from_str(&text).context("parsing move-posts response")?;
190        let url = value
191            .get("url")
192            .and_then(|v| v.as_str())
193            .map(|s| s.to_string())
194            .unwrap_or_else(|| format!("/t/{}", dest));
195        Ok(url)
196    }
197
198    /// Rename a topic via `PUT /t/{id}.json` with `title=`. Surfaces
199    /// Discourse's reserved-slug `403` (e.g. a topic whose slug is `contact`,
200    /// a system route) with a clear message rather than the generic forbidden
201    /// error.
202    pub fn set_topic_title(&self, topic_id: u64, title: &str) -> Result<()> {
203        let path = format!("/t/{}.json", topic_id);
204        let payload = [("title", title)];
205        let response = self.send_retrying(|| Ok(self.put(&path)?.form(&payload)))?;
206        let status = response.status();
207        let text = response.text().context("reading set-title response body")?;
208        if status == reqwest::StatusCode::FORBIDDEN {
209            return Err(anyhow!(
210                "topic {} title cannot be changed (reserved slug or insufficient permission)",
211                topic_id
212            ));
213        }
214        if !status.is_success() {
215            return Err(http_error("set title request", status, &text));
216        }
217        Ok(())
218    }
219
220    /// Update a post by ID. `opts` controls Discourse's edit side effects
221    /// (topic bump, revision history); [`PostEditOptions::default`] applies a
222    /// normal edit.
223    pub fn update_post(&self, post_id: u64, raw: &str, opts: PostEditOptions) -> Result<()> {
224        let path = format!("/posts/{}.json", post_id);
225        let payload = post_edit_payload(raw, opts);
226        let response = self.send_retrying(|| Ok(self.put(&path)?.form(&payload)))?;
227        let status = response.status();
228        if !status.is_success() {
229            let text = response
230                .text()
231                .unwrap_or_else(|_| "<failed to read response body>".to_string());
232            return Err(http_error("update post request", status, &text));
233        }
234        Ok(())
235    }
236
237    /// Create a new topic in a category.
238    pub fn create_topic(&self, category_id: u64, title: &str, raw: &str) -> Result<u64> {
239        let category = category_id.to_string();
240        let payload = [("title", title), ("raw", raw), ("category", &category)];
241        let response = self.send_retrying(|| Ok(self.post("/posts.json")?.form(&payload)))?;
242        let status = response.status();
243        let text = response.text().context("reading create response body")?;
244        if !status.is_success() {
245            return Err(http_error("create topic request", status, &text));
246        }
247        let body: CreatePostResponse =
248            serde_json::from_str(&text).context("parsing create topic response")?;
249        Ok(body.topic_id)
250    }
251
252    /// Send a private message. `recipients` is comma-joined into Discourse's
253    /// `target_recipients` field (usernames or group names accepted).
254    /// Returns the new topic_id of the PM thread.
255    pub fn create_private_message(
256        &self,
257        recipients: &[String],
258        title: &str,
259        raw: &str,
260    ) -> Result<u64> {
261        let recipients_csv = recipients.join(",");
262        let payload = [
263            ("title", title),
264            ("raw", raw),
265            ("archetype", "private_message"),
266            ("target_recipients", recipients_csv.as_str()),
267        ];
268        let response = self.send_retrying(|| Ok(self.post("/posts.json")?.form(&payload)))?;
269        let status = response.status();
270        let text = response.text().context("reading PM create response body")?;
271        if !status.is_success() {
272            return Err(http_error("create PM request", status, &text));
273        }
274        let body: CreatePostResponse =
275            serde_json::from_str(&text).context("parsing PM create response")?;
276        Ok(body.topic_id)
277    }
278
279    /// List private messages for the given user. `direction` is one of
280    /// `inbox` (received), `sent`, `archive`, `unread`, `new`. Returns
281    /// distilled topic summaries.
282    pub fn list_private_messages(
283        &self,
284        username: &str,
285        direction: &str,
286    ) -> Result<Vec<PmTopicSummary>> {
287        let path = match direction {
288            "inbox" => format!("/topics/private-messages/{}.json", username),
289            "sent" => format!("/topics/private-messages-sent/{}.json", username),
290            "archive" => format!("/topics/private-messages-archive/{}.json", username),
291            "unread" => format!("/topics/private-messages-unread/{}.json", username),
292            "new" => format!("/topics/private-messages-new/{}.json", username),
293            other => format!("/topics/private-messages-{}/{}.json", other, username),
294        };
295        let response = self.get(&path)?;
296        let status = response.status();
297        let text = response.text().context("reading PM list response")?;
298        if !status.is_success() {
299            return Err(http_error("PM list request", status, &text));
300        }
301        let value: Value = serde_json::from_str(&text).context("parsing PM list response")?;
302        let topics = value
303            .get("topic_list")
304            .and_then(|tl| tl.get("topics"))
305            .and_then(|t| t.as_array())
306            .map(|arr| {
307                arr.iter()
308                    .filter_map(|v| serde_json::from_value::<PmTopicSummary>(v.clone()).ok())
309                    .collect()
310            })
311            .unwrap_or_default();
312        Ok(topics)
313    }
314
315    /// Create a reply post in a topic.
316    pub fn create_post(&self, topic_id: u64, raw: &str) -> Result<u64> {
317        let topic = topic_id.to_string();
318        let payload = [("topic_id", topic.as_str()), ("raw", raw)];
319        let response = self.send_retrying(|| Ok(self.post("/posts.json")?.form(&payload)))?;
320        let status = response.status();
321        let text = response.text().context("reading create response body")?;
322        if !status.is_success() {
323            return Err(http_error("create post request", status, &text));
324        }
325        let body: CreatePostResponse =
326            serde_json::from_str(&text).context("parsing create post response")?;
327        Ok(body.id)
328    }
329}
330
331/// Build the urlencoded form payload for a `PUT /posts/{id}.json` edit.
332/// Always sends `post[raw]`; `post[no_bump]` / `post[skip_revision]` are added
333/// only when requested, so a default edit is byte-for-byte what `dsc` sent
334/// before these options existed.
335fn post_edit_payload(raw: &str, opts: PostEditOptions) -> Vec<(&'static str, &str)> {
336    let mut payload: Vec<(&'static str, &str)> = vec![("post[raw]", raw)];
337    if opts.no_bump {
338        payload.push(("post[no_bump]", "true"));
339    }
340    if opts.skip_revision {
341        payload.push(("post[skip_revision]", "true"));
342    }
343    payload
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    #[test]
351    fn default_edit_sends_only_raw() {
352        let payload = post_edit_payload("hello", PostEditOptions::default());
353        assert_eq!(payload, vec![("post[raw]", "hello")]);
354    }
355
356    #[test]
357    fn no_bump_adds_form_field() {
358        let payload = post_edit_payload(
359            "hi",
360            PostEditOptions {
361                no_bump: true,
362                skip_revision: false,
363            },
364        );
365        assert_eq!(
366            payload,
367            vec![("post[raw]", "hi"), ("post[no_bump]", "true")]
368        );
369    }
370
371    #[test]
372    fn skip_revision_adds_form_field() {
373        let payload = post_edit_payload(
374            "hi",
375            PostEditOptions {
376                no_bump: false,
377                skip_revision: true,
378            },
379        );
380        assert_eq!(
381            payload,
382            vec![("post[raw]", "hi"), ("post[skip_revision]", "true")]
383        );
384    }
385
386    #[test]
387    fn both_flags_add_both_fields() {
388        let payload = post_edit_payload(
389            "x",
390            PostEditOptions {
391                no_bump: true,
392                skip_revision: true,
393            },
394        );
395        assert_eq!(
396            payload,
397            vec![
398                ("post[raw]", "x"),
399                ("post[no_bump]", "true"),
400                ("post[skip_revision]", "true"),
401            ]
402        );
403    }
404}