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#[derive(Debug, Clone, Copy, Default)]
21pub struct PostEditOptions {
22 pub no_bump: bool,
25 pub skip_revision: bool,
28}
29
30#[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 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 pub fn fetch_topic_all_posts(&self, topic_id: u64) -> Result<TopicResponse> {
75 let mut topic = self.fetch_topic(topic_id, true)?;
76
77 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 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 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 pub fn fetch_post_raw(&self, post_id: u64) -> Result<Option<String>> {
132 Ok(self.fetch_post(post_id)?.raw)
133 }
134
135 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 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 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 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 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 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 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 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 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
331fn 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}