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    /// Rename a topic via `PUT /t/{id}.json` with `title=`. Surfaces
198    /// Discourse's reserved-slug `403` (e.g. a topic whose slug is `contact`,
199    /// a system route) with a clear message rather than the generic forbidden
200    /// error.
201    pub fn set_topic_title(&self, topic_id: u64, title: &str) -> Result<()> {
202        let path = format!("/t/{}.json", topic_id);
203        let payload = [("title", title)];
204        let response = self.send_retrying(|| Ok(self.put(&path)?.form(&payload)))?;
205        let status = response.status();
206        let text = response.text().context("reading set-title response body")?;
207        if status == reqwest::StatusCode::FORBIDDEN {
208            return Err(anyhow!(
209                "topic {} title cannot be changed (reserved slug or insufficient permission)",
210                topic_id
211            ));
212        }
213        if !status.is_success() {
214            return Err(http_error("set title request", status, &text));
215        }
216        Ok(())
217    }
218
219    /// Update a post by ID. `opts` controls Discourse's edit side effects
220    /// (topic bump, revision history); [`PostEditOptions::default`] applies a
221    /// normal edit.
222    pub fn update_post(&self, post_id: u64, raw: &str, opts: PostEditOptions) -> Result<()> {
223        let path = format!("/posts/{}.json", post_id);
224        let payload = post_edit_payload(raw, opts);
225        let response = self.send_retrying(|| Ok(self.put(&path)?.form(&payload)))?;
226        let status = response.status();
227        if !status.is_success() {
228            let text = response
229                .text()
230                .unwrap_or_else(|_| "<failed to read response body>".to_string());
231            return Err(http_error("update post request", status, &text));
232        }
233        Ok(())
234    }
235
236    /// Create a new topic in a category.
237    pub fn create_topic(&self, category_id: u64, title: &str, raw: &str) -> Result<u64> {
238        let category = category_id.to_string();
239        let payload = [("title", title), ("raw", raw), ("category", &category)];
240        let response = self.send_retrying(|| Ok(self.post("/posts.json")?.form(&payload)))?;
241        let status = response.status();
242        let text = response.text().context("reading create response body")?;
243        if !status.is_success() {
244            return Err(http_error("create topic request", status, &text));
245        }
246        let body: CreatePostResponse =
247            serde_json::from_str(&text).context("parsing create topic response")?;
248        Ok(body.topic_id)
249    }
250
251    /// Send a private message. `recipients` is comma-joined into Discourse's
252    /// `target_recipients` field (usernames or group names accepted).
253    /// Returns the new topic_id of the PM thread.
254    pub fn create_private_message(
255        &self,
256        recipients: &[String],
257        title: &str,
258        raw: &str,
259    ) -> Result<u64> {
260        let recipients_csv = recipients.join(",");
261        let payload = [
262            ("title", title),
263            ("raw", raw),
264            ("archetype", "private_message"),
265            ("target_recipients", recipients_csv.as_str()),
266        ];
267        let response = self.send_retrying(|| Ok(self.post("/posts.json")?.form(&payload)))?;
268        let status = response.status();
269        let text = response.text().context("reading PM create response body")?;
270        if !status.is_success() {
271            return Err(http_error("create PM request", status, &text));
272        }
273        let body: CreatePostResponse =
274            serde_json::from_str(&text).context("parsing PM create response")?;
275        Ok(body.topic_id)
276    }
277
278    /// List private messages for the given user. `direction` is one of
279    /// `inbox` (received), `sent`, `archive`, `unread`, `new`. Returns
280    /// distilled topic summaries.
281    pub fn list_private_messages(
282        &self,
283        username: &str,
284        direction: &str,
285    ) -> Result<Vec<PmTopicSummary>> {
286        let path = match direction {
287            "inbox" => format!("/topics/private-messages/{}.json", username),
288            "sent" => format!("/topics/private-messages-sent/{}.json", username),
289            "archive" => format!("/topics/private-messages-archive/{}.json", username),
290            "unread" => format!("/topics/private-messages-unread/{}.json", username),
291            "new" => format!("/topics/private-messages-new/{}.json", username),
292            other => format!("/topics/private-messages-{}/{}.json", other, username),
293        };
294        let response = self.get(&path)?;
295        let status = response.status();
296        let text = response.text().context("reading PM list response")?;
297        if !status.is_success() {
298            return Err(http_error("PM list request", status, &text));
299        }
300        let value: Value = serde_json::from_str(&text).context("parsing PM list response")?;
301        let topics = value
302            .get("topic_list")
303            .and_then(|tl| tl.get("topics"))
304            .and_then(|t| t.as_array())
305            .map(|arr| {
306                arr.iter()
307                    .filter_map(|v| serde_json::from_value::<PmTopicSummary>(v.clone()).ok())
308                    .collect()
309            })
310            .unwrap_or_default();
311        Ok(topics)
312    }
313
314    /// Create a reply post in a topic.
315    pub fn create_post(&self, topic_id: u64, raw: &str) -> Result<u64> {
316        let topic = topic_id.to_string();
317        let payload = [("topic_id", topic.as_str()), ("raw", raw)];
318        let response = self.send_retrying(|| Ok(self.post("/posts.json")?.form(&payload)))?;
319        let status = response.status();
320        let text = response.text().context("reading create response body")?;
321        if !status.is_success() {
322            return Err(http_error("create post request", status, &text));
323        }
324        let body: CreatePostResponse =
325            serde_json::from_str(&text).context("parsing create post response")?;
326        Ok(body.id)
327    }
328}
329
330/// Build the urlencoded form payload for a `PUT /posts/{id}.json` edit.
331/// Always sends `post[raw]`; `post[no_bump]` / `post[skip_revision]` are added
332/// only when requested, so a default edit is byte-for-byte what `dsc` sent
333/// before these options existed.
334fn post_edit_payload(raw: &str, opts: PostEditOptions) -> Vec<(&'static str, &str)> {
335    let mut payload: Vec<(&'static str, &str)> = vec![("post[raw]", raw)];
336    if opts.no_bump {
337        payload.push(("post[no_bump]", "true"));
338    }
339    if opts.skip_revision {
340        payload.push(("post[skip_revision]", "true"));
341    }
342    payload
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    #[test]
350    fn default_edit_sends_only_raw() {
351        let payload = post_edit_payload("hello", PostEditOptions::default());
352        assert_eq!(payload, vec![("post[raw]", "hello")]);
353    }
354
355    #[test]
356    fn no_bump_adds_form_field() {
357        let payload = post_edit_payload(
358            "hi",
359            PostEditOptions {
360                no_bump: true,
361                skip_revision: false,
362            },
363        );
364        assert_eq!(
365            payload,
366            vec![("post[raw]", "hi"), ("post[no_bump]", "true")]
367        );
368    }
369
370    #[test]
371    fn skip_revision_adds_form_field() {
372        let payload = post_edit_payload(
373            "hi",
374            PostEditOptions {
375                no_bump: false,
376                skip_revision: true,
377            },
378        );
379        assert_eq!(
380            payload,
381            vec![("post[raw]", "hi"), ("post[skip_revision]", "true")]
382        );
383    }
384
385    #[test]
386    fn both_flags_add_both_fields() {
387        let payload = post_edit_payload(
388            "x",
389            PostEditOptions {
390                no_bump: true,
391                skip_revision: true,
392            },
393        );
394        assert_eq!(
395            payload,
396            vec![
397                ("post[raw]", "x"),
398                ("post[no_bump]", "true"),
399                ("post[skip_revision]", "true"),
400            ]
401        );
402    }
403}