serenity/model/
misc.rs

1//! Miscellaneous helper traits, enums, and structs for models.
2
3#[cfg(all(feature = "model", feature = "utils"))]
4use std::error::Error as StdError;
5use std::fmt;
6use std::fmt::Write;
7#[cfg(all(feature = "model", feature = "utils"))]
8use std::result::Result as StdResult;
9use std::str::FromStr;
10
11use arrayvec::ArrayString;
12
13use super::prelude::*;
14#[cfg(all(feature = "model", any(feature = "cache", feature = "utils")))]
15use crate::utils;
16
17/// Hides the implementation detail of ImageHash as an enum.
18#[cfg_attr(feature = "typesize", derive(typesize::derive::TypeSize))]
19#[derive(Clone, Copy, PartialEq, Eq)]
20enum ImageHashInner {
21    Normal { hash: [u8; 16], is_animated: bool },
22    Clyde,
23}
24
25/// An image hash returned from the Discord API.
26///
27/// This type can be constructed via it's [`FromStr`] implementation, and can be turned into it's
28/// cannonical representation via [`std::fmt::Display`] or [`serde::Serialize`].
29///
30/// # Example
31/// ```rust
32/// use serenity::model::misc::ImageHash;
33///
34/// let image_hash: ImageHash = "f1eff024d9c85339c877985229ed8fec".parse().unwrap();
35/// assert_eq!(image_hash.to_string(), String::from("f1eff024d9c85339c877985229ed8fec"));
36/// ```
37
38#[cfg_attr(feature = "typesize", derive(typesize::derive::TypeSize))]
39#[derive(Clone, Copy, PartialEq, Eq)]
40pub struct ImageHash(ImageHashInner);
41
42impl ImageHash {
43    /// Returns if the linked image is animated, which means the hash starts with `a_`.
44    ///
45    /// # Example
46    /// ```rust
47    /// use serenity::model::misc::ImageHash;
48    ///
49    /// let animated_hash: ImageHash = "a_e3c0db7f38777778fb43081f8746ebc9".parse().unwrap();
50    /// assert!(animated_hash.is_animated());
51    /// ```
52    #[must_use]
53    pub fn is_animated(&self) -> bool {
54        match &self.0 {
55            ImageHashInner::Normal {
56                is_animated, ..
57            } => *is_animated,
58            ImageHashInner::Clyde => true,
59        }
60    }
61
62    #[must_use]
63    fn into_arraystring(self) -> ArrayString<34> {
64        let ImageHashInner::Normal {
65            hash,
66            is_animated,
67        } = &self.0
68        else {
69            return ArrayString::from_str("clyde").expect("the string clyde is less than 34 chars");
70        };
71
72        let mut out = ArrayString::new();
73        if *is_animated {
74            out.push_str("a_");
75        }
76
77        for byte in hash {
78            write!(out, "{byte:02x}").expect("ImageHash should fit into 34 char ArrayString");
79        }
80
81        out
82    }
83}
84
85impl std::fmt::Debug for ImageHash {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        f.write_str("\"")?;
88        <Self as std::fmt::Display>::fmt(self, f)?;
89        f.write_str("\"")
90    }
91}
92
93impl serde::Serialize for ImageHash {
94    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
95        self.into_arraystring().serialize(serializer)
96    }
97}
98
99impl<'de> serde::Deserialize<'de> for ImageHash {
100    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
101        let helper = ArrayString::<34>::deserialize(deserializer)?;
102        Self::from_str(&helper).map_err(serde::de::Error::custom)
103    }
104}
105
106impl std::fmt::Display for ImageHash {
107    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108        self.into_arraystring().fmt(f)
109    }
110}
111
112/// An error returned when [`ImageHash`] is passed an erronous value.
113#[derive(Debug, Clone)]
114pub enum ImageHashParseError {
115    /// The given hash was not a valid [`ImageHash`] length, containing the invalid length.
116    InvalidLength(usize),
117}
118
119impl std::error::Error for ImageHashParseError {}
120
121impl std::fmt::Display for ImageHashParseError {
122    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123        match self {
124            Self::InvalidLength(length) => {
125                write!(f, "Invalid length {length}, expected 32 or 34 characters")
126            },
127        }
128    }
129}
130
131impl std::str::FromStr for ImageHash {
132    type Err = ImageHashParseError;
133
134    fn from_str(s: &str) -> StdResult<Self, Self::Err> {
135        let (hex, is_animated) = if s.len() == 34 && s.starts_with("a_") {
136            (&s[2..], true)
137        } else if s.len() == 32 {
138            (s, false)
139        } else if s == "clyde" {
140            return Ok(Self(ImageHashInner::Clyde));
141        } else {
142            return Err(Self::Err::InvalidLength(s.len()));
143        };
144
145        let mut hash = [0u8; 16];
146        for i in (0..hex.len()).step_by(2) {
147            let hex_byte = &hex[i..i + 2];
148            hash[i / 2] = u8::from_str_radix(hex_byte, 16).unwrap_or_else(|err| {
149                tracing::warn!("Invalid byte in ImageHash ({s}): {err}");
150                0
151            });
152        }
153
154        Ok(Self(ImageHashInner::Normal {
155            is_animated,
156            hash,
157        }))
158    }
159}
160
161/// A version of an emoji used only when solely the animated state, Id, and name are known.
162///
163/// [Discord docs](https://discord.com/developers/docs/topics/gateway#activity-object-activity-emoji).
164#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
165#[non_exhaustive]
166pub struct EmojiIdentifier {
167    /// Whether the emoji is animated
168    pub animated: bool,
169    /// The Id of the emoji.
170    pub id: EmojiId,
171    /// The name of the emoji. It must be at least 2 characters long and can only contain
172    /// alphanumeric characters and underscores.
173    pub name: String,
174}
175
176#[cfg(all(feature = "model", feature = "utils"))]
177impl EmojiIdentifier {
178    /// Generates a URL to the emoji's image.
179    #[must_use]
180    pub fn url(&self) -> String {
181        let ext = if self.animated { "gif" } else { "png" };
182
183        cdn!("/emojis/{}.{}", self.id, ext)
184    }
185}
186
187#[cfg(all(feature = "model", feature = "utils"))]
188impl fmt::Display for EmojiIdentifier {
189    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
190        if self.animated {
191            f.write_str("<a:")?;
192        } else {
193            f.write_str("<:")?;
194        }
195
196        f.write_str(&self.name)?;
197
198        f.write_char(':')?;
199        fmt::Display::fmt(&self.id, f)?;
200        f.write_char('>')
201    }
202}
203
204#[derive(Debug)]
205#[cfg(all(feature = "model", feature = "utils"))]
206pub struct EmojiIdentifierParseError {
207    parsed_string: String,
208}
209
210#[cfg(all(feature = "model", feature = "utils"))]
211impl fmt::Display for EmojiIdentifierParseError {
212    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
213        write!(f, "`{}` is not a valid emoji identifier", self.parsed_string)
214    }
215}
216
217#[cfg(all(feature = "model", feature = "utils"))]
218impl StdError for EmojiIdentifierParseError {}
219
220#[cfg(all(feature = "model", feature = "utils"))]
221impl FromStr for EmojiIdentifier {
222    type Err = EmojiIdentifierParseError;
223
224    fn from_str(s: &str) -> StdResult<Self, Self::Err> {
225        utils::parse_emoji(s).ok_or_else(|| EmojiIdentifierParseError {
226            parsed_string: s.to_owned(),
227        })
228    }
229}
230
231/// An incident retrieved from the Discord status page.
232///
233/// This is not necessarily a representation of an ongoing incident.
234///
235/// [Discord docs](https://discordstatus.com/api) (see "Unresolved incident" example)
236#[derive(Clone, Debug, Deserialize, Serialize)]
237#[non_exhaustive]
238pub struct Incident {
239    pub created_at: String,
240    pub id: String,
241    pub impact: String,
242    pub incident_updates: Vec<IncidentUpdate>,
243    pub monitoring_at: Option<String>,
244    pub name: String,
245    pub page_id: String,
246    pub resolved_at: Option<String>,
247    pub shortlink: String,
248    pub status: String,
249    pub updated_at: String,
250}
251
252/// An update to an incident from the Discord status page.
253///
254/// This will typically state what new information has been discovered about an incident.
255///
256/// [Discord docs](https://discordstatus.com/api) (see "Unresolved incident" example)
257#[derive(Clone, Debug, Deserialize, Serialize)]
258#[non_exhaustive]
259pub struct IncidentUpdate {
260    pub body: String,
261    pub created_at: String,
262    pub display_at: String,
263    pub id: String,
264    pub incident_id: String,
265    pub status: String,
266    pub updated_at: String,
267}
268
269/// A Discord status maintenance message. This can be either for active maintenances or for
270/// scheduled maintenances.
271///
272/// [Discord docs](https://discordstatus.com/api) (see "scheduled maintenances" examples)
273#[derive(Clone, Debug, Deserialize, Serialize)]
274#[non_exhaustive]
275pub struct Maintenance {
276    pub created_at: String,
277    pub id: String,
278    pub impact: String,
279    pub incident_updates: Vec<IncidentUpdate>,
280    pub monitoring_at: Option<String>,
281    pub name: String,
282    pub page_id: String,
283    pub resolved_at: Option<String>,
284    pub scheduled_for: String,
285    pub scheduled_until: String,
286    pub shortlink: String,
287    pub status: String,
288    pub updated_at: String,
289}
290
291#[cfg(test)]
292mod test {
293    use crate::model::prelude::*;
294
295    #[test]
296    fn test_formatters() {
297        assert_eq!(ChannelId::new(1).to_string(), "1");
298        assert_eq!(EmojiId::new(2).to_string(), "2");
299        assert_eq!(GuildId::new(3).to_string(), "3");
300        assert_eq!(RoleId::new(4).to_string(), "4");
301        assert_eq!(UserId::new(5).to_string(), "5");
302    }
303}