presenceforge/activity/
types.rs

1#![allow(clippy::collapsible_if)]
2
3use serde::{Deserialize, Serialize};
4
5/// Rich Presence Activity
6#[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    /// Validate the activity according to Discord's requirements
36    ///
37    /// # Returns
38    ///
39    /// Ok(()) if valid, or Err(String) with the reason if invalid
40    pub fn validate(&self) -> Result<(), String> {
41        // Check text field lengths
42        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        // Validate buttons
55        if let Some(buttons) = &self.buttons {
56            // Discord allows a maximum of 2 buttons
57            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                // Validate URL format (simple check)
71                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        // Buttons and secrets are mutually exclusive. If any secret field is set,
78        // the activity MUST NOT contain interactive buttons. We only consider
79        // secrets "present" when at least one of the inner secret fields is
80        // Some(...). An Option<ActivitySecrets> that exists but contains no
81        // secrets is considered empty and does not conflict with buttons.
82        #[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        // Validate asset keys
96        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        // Validate party size
123        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/// Activity timestamps
134#[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/// Activity assets (images)
144#[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/// Activity party information
160#[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]>, // [current, max]
167}
168
169/// Activity secrets for join/spectate
170#[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/// Activity button
184#[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        // Build an activity that contains both a button and a secret and
291        // ensure validate() rejects it.
292        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}