Skip to main content

dsc/api/
emoji.rs

1use super::client::DiscourseClient;
2use super::error::http_error;
3use super::models::CustomEmoji;
4use anyhow::{Context, Result, anyhow};
5use reqwest::StatusCode;
6use serde_json::Value;
7use std::path::Path;
8
9impl DiscourseClient {
10    /// Upload a custom emoji. Retries on 429 via the shared client helper.
11    pub fn upload_emoji(&self, emoji_path: &Path, emoji_name: &str) -> Result<()> {
12        let make_form_legacy = || -> Result<reqwest::blocking::multipart::Form> {
13            let file = std::fs::read(emoji_path)
14                .with_context(|| format!("reading {}", emoji_path.display()))?;
15            let part = reqwest::blocking::multipart::Part::bytes(file)
16                .file_name(
17                    emoji_path
18                        .file_name()
19                        .and_then(|s| s.to_str())
20                        .unwrap_or("emoji.png")
21                        .to_string(),
22                )
23                .mime_str("image/png")
24                .context("setting emoji mime")?;
25            Ok(reqwest::blocking::multipart::Form::new()
26                .part("emoji[image]", part)
27                .text("emoji[name]", emoji_name.to_string()))
28        };
29
30        let make_form_v2 = || -> Result<reqwest::blocking::multipart::Form> {
31            let file = std::fs::read(emoji_path)
32                .with_context(|| format!("reading {}", emoji_path.display()))?;
33            let part = reqwest::blocking::multipart::Part::bytes(file)
34                .file_name(
35                    emoji_path
36                        .file_name()
37                        .and_then(|s| s.to_str())
38                        .unwrap_or("emoji.png")
39                        .to_string(),
40                )
41                .mime_str("image/png")
42                .context("setting emoji mime")?;
43            Ok(reqwest::blocking::multipart::Form::new()
44                .part("file", part)
45                .text("name", emoji_name.to_string()))
46        };
47
48        let upload_v2_json_path = emoji_admin_path("/admin/config/emoji.json");
49        let upload_json_path = emoji_admin_path("/admin/customize/emojis.json");
50        let upload_path = emoji_admin_path("/admin/customize/emojis");
51
52        let mut response = self
53            .send_retrying(|| Ok(self.post(&upload_v2_json_path)?.multipart(make_form_v2()?)))?;
54        if response.status() == StatusCode::NOT_FOUND {
55            response = self.send_retrying(|| {
56                Ok(self.post(&upload_json_path)?.multipart(make_form_legacy()?))
57            })?;
58        }
59        if response.status() == StatusCode::NOT_FOUND {
60            response = self
61                .send_retrying(|| Ok(self.post(&upload_path)?.multipart(make_form_legacy()?)))?;
62        }
63        if !response.status().is_success() {
64            let status = response.status();
65            if matches!(
66                status,
67                StatusCode::NOT_FOUND | StatusCode::FORBIDDEN | StatusCode::UNAUTHORIZED
68            ) {
69                return Err(anyhow!(
70                    "emoji upload failed with {} (requires an admin API key)",
71                    status
72                ));
73            }
74            let text = response
75                .text()
76                .unwrap_or_else(|_| "<failed to read response body>".to_string());
77            return Err(anyhow!("emoji upload failed with {}: {}", status, text));
78        }
79        Ok(())
80    }
81
82    /// List custom emojis.
83    pub fn list_custom_emojis(&self) -> Result<Vec<CustomEmoji>> {
84        if let Some(emojis) = self.list_admin_emojis()? {
85            return Ok(emojis);
86        }
87        if let Some(emojis) = self.list_admin_config_emojis()? {
88            return Ok(emojis);
89        }
90        self.list_public_emojis()
91    }
92
93    fn list_admin_emojis(&self) -> Result<Option<Vec<CustomEmoji>>> {
94        let path = emoji_admin_path("/admin/customize/emojis.json");
95        let response = self.get(&path)?;
96        let status = response.status();
97        let text = response.text().context("reading emoji list response")?;
98        if !status.is_success() {
99            if status == StatusCode::NOT_FOUND {
100                return Ok(None);
101            }
102            return Err(http_error("emoji list request", status, &text));
103        }
104        let value: Value = serde_json::from_str(&text).context("parsing emoji list json")?;
105        let emojis = if let Some(arr) = value.as_array() {
106            extract_emojis_from_array(arr, self.baseurl())
107        } else if let Some(val) = value.get("emojis") {
108            extract_emojis_from_value(val, self.baseurl())
109        } else if let Some(val) = value.get("custom_emoji") {
110            extract_emojis_from_value(val, self.baseurl())
111        } else if let Some(val) = value.get("custom") {
112            extract_emojis_from_value(val, self.baseurl())
113        } else if let Some(map) = value.as_object() {
114            let mut out = Vec::new();
115            extract_emojis_from_map(map, self.baseurl(), &mut out);
116            out
117        } else {
118            Vec::new()
119        };
120        Ok(Some(emojis))
121    }
122
123    fn list_public_emojis(&self) -> Result<Vec<CustomEmoji>> {
124        let response = self.get("/emoji.json")?;
125        let status = response.status();
126        let text = response.text().context("reading emoji.json response")?;
127        if status == StatusCode::NOT_FOUND {
128            return Ok(Vec::new());
129        }
130        if !status.is_success() {
131            return Err(http_error("emoji.json request", status, &text));
132        }
133        let value: Value = serde_json::from_str(&text).context("parsing emoji.json")?;
134        let baseurl = self.baseurl().trim_end_matches('/');
135        let mut out = Vec::new();
136        if let Some(val) = value.get("custom_emoji") {
137            out.extend(extract_emojis_from_value(val, baseurl));
138        }
139        if let Some(val) = value.get("custom") {
140            out.extend(extract_emojis_from_value(val, baseurl));
141        }
142        if out.is_empty() {
143            if let Some(val) = value.get("emoji") {
144                out.extend(extract_emojis_from_value(val, baseurl));
145            }
146        }
147        Ok(out)
148    }
149
150    fn list_admin_config_emojis(&self) -> Result<Option<Vec<CustomEmoji>>> {
151        let path = emoji_admin_path("/admin/config/emoji.json");
152        let response = self.get(&path)?;
153        let status = response.status();
154        let text = response
155            .text()
156            .context("reading admin config emoji response")?;
157        if status == StatusCode::NOT_FOUND {
158            return Ok(None);
159        }
160        if !status.is_success() {
161            return Err(http_error("admin config emoji request", status, &text));
162        }
163        let value: Value =
164            serde_json::from_str(&text).context("parsing admin config emoji json")?;
165        if let Some(val) = value.get("emojis") {
166            return Ok(Some(extract_emojis_from_value(val, self.baseurl())));
167        }
168        Ok(Some(extract_emojis_from_value(&value, self.baseurl())))
169    }
170}
171
172fn emoji_admin_path(path: &str) -> String {
173    let client_id = match std::env::var("DSC_EMOJI_CLIENT_ID") {
174        Ok(value) => value,
175        Err(_) => return path.to_string(),
176    };
177    let client_id = client_id.trim();
178    if client_id.is_empty() || path.contains("client_id=") {
179        return path.to_string();
180    }
181    let sep = if path.contains('?') { '&' } else { '?' };
182    format!("{path}{sep}client_id={client_id}")
183}
184
185fn extract_emojis_from_array(emojis: &[Value], baseurl: &str) -> Vec<CustomEmoji> {
186    let mut out = Vec::new();
187    for item in emojis.iter() {
188        let name = item.get("name").and_then(|v| v.as_str());
189        let url = item
190            .get("url")
191            .and_then(|v| v.as_str())
192            .or_else(|| item.get("image_url").and_then(|v| v.as_str()));
193        if let (Some(name), Some(url)) = (name, url) {
194            out.push(CustomEmoji {
195                name: name.to_string(),
196                url: normalize_emoji_url(baseurl, url),
197            });
198        }
199    }
200    out
201}
202
203fn extract_emojis_from_map(
204    map: &serde_json::Map<String, Value>,
205    baseurl: &str,
206    out: &mut Vec<CustomEmoji>,
207) {
208    for (name, value) in map.iter() {
209        let url = value
210            .as_str()
211            .or_else(|| value.get("url").and_then(|v| v.as_str()))
212            .or_else(|| value.get("image_url").and_then(|v| v.as_str()))
213            .or_else(|| value.get("path").and_then(|v| v.as_str()));
214        if let Some(url) = url {
215            out.push(CustomEmoji {
216                name: name.to_string(),
217                url: normalize_emoji_url(baseurl, url),
218            });
219        }
220    }
221}
222
223fn extract_emojis_from_value(value: &Value, baseurl: &str) -> Vec<CustomEmoji> {
224    match value {
225        Value::Array(arr) => extract_emojis_from_array(arr, baseurl),
226        Value::Object(map) => {
227            let name = map.get("name").and_then(|v| v.as_str());
228            let url = map
229                .get("url")
230                .and_then(|v| v.as_str())
231                .or_else(|| map.get("image_url").and_then(|v| v.as_str()))
232                .or_else(|| map.get("path").and_then(|v| v.as_str()));
233            if let (Some(name), Some(url)) = (name, url) {
234                return vec![CustomEmoji {
235                    name: name.to_string(),
236                    url: normalize_emoji_url(baseurl, url),
237                }];
238            }
239            let mut out = Vec::new();
240            extract_emojis_from_map(map, baseurl, &mut out);
241            out
242        }
243        _ => Vec::new(),
244    }
245}
246
247fn normalize_emoji_url(baseurl: &str, url: &str) -> String {
248    if url.starts_with("http://") || url.starts_with("https://") {
249        url.to_string()
250    } else if url.starts_with("//") {
251        let scheme = if baseurl.starts_with("http://") {
252            "http:"
253        } else {
254            "https:"
255        };
256        format!("{}{}", scheme, url)
257    } else if url.starts_with('/') {
258        format!("{}{}", baseurl, url)
259    } else {
260        format!("{}/{}", baseurl, url)
261    }
262}
263