presenceforge/activity/
types.rs1#![allow(clippy::collapsible_if)]
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Serialize, Deserialize, Default)]
7pub struct Activity {
8 #[serde(skip_serializing_if = "Option::is_none")]
9 pub state: Option<String>,
10
11 #[serde(skip_serializing_if = "Option::is_none")]
12 pub details: Option<String>,
13
14 #[serde(skip_serializing_if = "Option::is_none")]
15 pub timestamps: Option<ActivityTimestamps>,
16
17 #[serde(skip_serializing_if = "Option::is_none")]
18 pub assets: Option<ActivityAssets>,
19
20 #[serde(skip_serializing_if = "Option::is_none")]
21 pub party: Option<ActivityParty>,
22
23 #[cfg(feature = "secrets")]
24 #[serde(skip_serializing_if = "Option::is_none")]
25 pub secrets: Option<ActivitySecrets>,
26
27 #[serde(skip_serializing_if = "Option::is_none")]
28 pub buttons: Option<Vec<ActivityButton>>,
29
30 #[serde(skip_serializing_if = "Option::is_none")]
31 pub instance: Option<bool>,
32}
33
34impl Activity {
35 pub fn validate(&self) -> Result<(), String> {
41 if let Some(state) = &self.state {
43 if state.len() > 128 {
44 return Err("State must be 128 characters or less".to_string());
45 }
46 }
47
48 if let Some(details) = &self.details {
49 if details.len() > 128 {
50 return Err("Details must be 128 characters or less".to_string());
51 }
52 }
53
54 if let Some(buttons) = &self.buttons {
56 if buttons.len() > 2 {
58 return Err("Discord allows a maximum of 2 buttons".to_string());
59 }
60
61 for button in buttons {
62 if button.label.len() > 32 {
63 return Err("Button label must be 32 characters or less".to_string());
64 }
65
66 if button.url.len() > 512 {
67 return Err("Button URL must be 512 characters or less".to_string());
68 }
69
70 if !button.url.starts_with("http://") && !button.url.starts_with("https://") {
72 return Err("Button URL must start with http:// or https://".to_string());
73 }
74 }
75 }
76
77 #[cfg(feature = "secrets")]
83 {
84 if self.buttons.is_some()
85 && self
86 .secrets
87 .as_ref()
88 .map(|s| s.join.is_some() || s.spectate.is_some() || s.match_secret.is_some())
89 .unwrap_or(false)
90 {
91 return Err("Buttons and secrets cannot coexist in the same Activity".to_string());
92 }
93 }
94
95 if let Some(assets) = &self.assets {
97 if let Some(large_image) = &assets.large_image {
98 if large_image.len() > 256 {
99 return Err("Large image key must be 256 characters or less".to_string());
100 }
101 }
102
103 if let Some(small_image) = &assets.small_image {
104 if small_image.len() > 256 {
105 return Err("Small image key must be 256 characters or less".to_string());
106 }
107 }
108
109 if let Some(large_text) = &assets.large_text {
110 if large_text.len() > 128 {
111 return Err("Large text must be 128 characters or less".to_string());
112 }
113 }
114
115 if let Some(small_text) = &assets.small_text {
116 if small_text.len() > 128 {
117 return Err("Small text must be 128 characters or less".to_string());
118 }
119 }
120 }
121
122 if let Some(size) = self.party.as_ref().and_then(|n| n.size) {
124 if size[0] > size[1] {
125 return Err("Current party size cannot be greater than max party size".to_string());
126 }
127 }
128
129 Ok(())
130 }
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize, Default)]
135pub struct ActivityTimestamps {
136 #[serde(skip_serializing_if = "Option::is_none")]
137 pub start: Option<u64>,
138
139 #[serde(skip_serializing_if = "Option::is_none")]
140 pub end: Option<i64>,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize, Default)]
145pub struct ActivityAssets {
146 #[serde(skip_serializing_if = "Option::is_none")]
147 pub large_image: Option<String>,
148
149 #[serde(skip_serializing_if = "Option::is_none")]
150 pub large_text: Option<String>,
151
152 #[serde(skip_serializing_if = "Option::is_none")]
153 pub small_image: Option<String>,
154
155 #[serde(skip_serializing_if = "Option::is_none")]
156 pub small_text: Option<String>,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct ActivityParty {
162 #[serde(skip_serializing_if = "Option::is_none")]
163 pub id: Option<String>,
164
165 #[serde(skip_serializing_if = "Option::is_none")]
166 pub size: Option<[u32; 2]>, }
168
169#[cfg(feature = "secrets")]
171#[derive(Debug, Clone, Serialize, Deserialize, Default)]
172pub struct ActivitySecrets {
173 #[serde(skip_serializing_if = "Option::is_none")]
174 pub join: Option<String>,
175
176 #[serde(skip_serializing_if = "Option::is_none")]
177 pub spectate: Option<String>,
178
179 #[serde(rename = "match", skip_serializing_if = "Option::is_none")]
180 pub match_secret: Option<String>,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct ActivityButton {
186 pub label: String,
187 pub url: String,
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193
194 fn activity_with_button(label: &str, url: &str) -> Activity {
195 Activity {
196 buttons: Some(vec![ActivityButton {
197 label: label.to_string(),
198 url: url.to_string(),
199 }]),
200 ..Activity::default()
201 }
202 }
203
204 #[test]
205 fn valid_activity_passes_validation() {
206 let activity = Activity {
207 state: Some("Exploring".to_string()),
208 details: Some("Testing".to_string()),
209 assets: Some(ActivityAssets {
210 large_image: Some("logo".to_string()),
211 large_text: Some("Logo".to_string()),
212 small_image: Some("icon".to_string()),
213 small_text: Some("Icon".to_string()),
214 }),
215 party: Some(ActivityParty {
216 id: Some("party".to_string()),
217 size: Some([1, 4]),
218 }),
219 buttons: Some(vec![
220 ActivityButton {
221 label: "Join".to_string(),
222 url: "https://example.com/join".to_string(),
223 },
224 ActivityButton {
225 label: "Watch".to_string(),
226 url: "https://example.com/watch".to_string(),
227 },
228 ]),
229 ..Default::default()
230 };
231
232 assert!(activity.validate().is_ok());
233 }
234
235 #[test]
236 fn state_over_character_limit_fails() {
237 let activity = Activity {
238 state: Some("a".repeat(129)),
239 ..Default::default()
240 };
241 let error = activity.validate().unwrap_err();
242 assert!(error.contains("128"));
243 }
244
245 #[test]
246 fn button_label_too_long_fails() {
247 let activity = activity_with_button(&"x".repeat(33), "https://example.com");
248 let error = activity.validate().unwrap_err();
249 assert!(error.contains("Button label"));
250 }
251
252 #[test]
253 fn button_url_without_scheme_fails() {
254 let activity = activity_with_button("Join", "example.com");
255 let error = activity.validate().unwrap_err();
256 assert!(error.contains("http://"));
257 }
258
259 #[test]
260 fn asset_key_too_long_fails() {
261 let activity = Activity {
262 assets: Some(ActivityAssets {
263 large_image: Some("y".repeat(257)),
264 ..ActivityAssets::default()
265 }),
266 ..Default::default()
267 };
268
269 let error = activity.validate().unwrap_err();
270 assert!(error.contains("Large image key"));
271 }
272
273 #[test]
274 fn party_size_greater_than_max_fails() {
275 let activity = Activity {
276 party: Some(ActivityParty {
277 id: Some("party".to_string()),
278 size: Some([5, 4]),
279 }),
280 ..Default::default()
281 };
282
283 let error = activity.validate().unwrap_err();
284 assert!(error.contains("Current party size"));
285 }
286
287 #[test]
288 #[cfg(feature = "secrets")]
289 fn buttons_and_secrets_cannot_coexist() {
290 let activity = Activity {
293 buttons: Some(vec![ActivityButton {
294 label: "Join".to_string(),
295 url: "https://example.com/join".to_string(),
296 }]),
297 secrets: Some(ActivitySecrets {
298 join: Some("secret".to_string()),
299 ..ActivitySecrets::default()
300 }),
301 ..Default::default()
302 };
303
304 let err = activity.validate().unwrap_err();
305 assert!(err.contains("Buttons and secrets cannot coexist"));
306 }
307}