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 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 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 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 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 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 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
330fn 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}