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.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 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 pub fn fetch_post_raw(&self, post_id: u64) -> Result<Option<String>> {
130 Ok(self.fetch_post(post_id)?.raw)
131 }
132
133 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 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 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 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 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 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 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 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
308fn 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}