Skip to main content

composure/types/
cdn.rs

1//! [CDN](https://discord.com/developers/docs/reference#image-formatting) URLs of images: avatars,
2//! guild icons, splashes and banners, role icons, emojis and stickers. Each image has a `*_url`
3//! method (`None` when the object has no such image) and a `*_url_with(format, size)` variant.
4
5use std::fmt::{Display, Write as _};
6
7use crate::types::{
8    Emoji, Guild, GuildPreview, Member, PartialEmoji, PartialGuild, PartialMember, Role, Snowflake,
9    Sticker, StickerItem, User,
10};
11
12/// Base URL of Discord's CDN
13pub const CDN_BASE: &str = "https://cdn.discordapp.com";
14
15/// Where sticker GIFs are served: they don't use [`CDN_BASE`]
16const MEDIA_BASE: &str = "https://media.discordapp.net";
17
18/// [Image formats](https://discord.com/developers/docs/reference#image-formatting-image-formats) of CDN images
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum ImageFormat {
21    /// `.png`
22    Png,
23    /// `.jpg`
24    Jpeg,
25    /// `.webp`; animated for `a_` hashes
26    Webp,
27    /// `.gif`, for animated images
28    Gif,
29}
30
31impl ImageFormat {
32    /// The file extension, without the dot: `png`, `jpg`, `webp` or `gif`
33    pub const fn extension(self) -> &'static str {
34        match self {
35            ImageFormat::Png => "png",
36            ImageFormat::Jpeg => "jpg",
37            ImageFormat::Webp => "webp",
38            ImageFormat::Gif => "gif",
39        }
40    }
41
42    /// The default format of the image with this hash: [`Gif`](Self::Gif) for animated (`a_`)
43    /// hashes, else [`Png`](Self::Png)
44    pub fn of_hash(hash: &str) -> Self {
45        if is_animated(hash) {
46            ImageFormat::Gif
47        } else {
48            ImageFormat::Png
49        }
50    }
51}
52
53fn is_animated(hash: &str) -> bool {
54    hash.starts_with("a_")
55}
56
57/// The URL of the CDN image `hash` under `path` (`avatars/{user_id}`, ...), in `format`
58/// ([`ImageFormat::of_hash`] by default) and `size` pixels wide, a power of two from 16 to 4096
59/// (Discord's default size when `None`)
60pub fn image_url(
61    path: impl Display,
62    hash: &str,
63    format: Option<ImageFormat>,
64    size: Option<u16>,
65) -> String {
66    debug_assert!(
67        size.is_none_or(|size| size.is_power_of_two() && (16..=4096).contains(&size)),
68        "CDN image sizes are powers of two from 16 to 4096, not {size:?}"
69    );
70    let format = format.unwrap_or_else(|| ImageFormat::of_hash(hash));
71    let mut url = format!("{CDN_BASE}/{path}/{hash}.{}", format.extension());
72    let mut separator = '?';
73    if let Some(size) = size {
74        write!(url, "{separator}size={size}").unwrap();
75        separator = '&';
76    }
77    if format == ImageFormat::Webp && is_animated(hash) {
78        write!(url, "{separator}animated=true").unwrap();
79    }
80    url
81}
82
83/// `hash`'s URL under `path`, if there is a hash
84fn hashed(
85    hash: Option<&str>,
86    path: impl Display,
87    format: Option<ImageFormat>,
88    size: Option<u16>,
89) -> Option<String> {
90    hash.map(|hash| image_url(path, hash, format, size))
91}
92
93impl User {
94    /// The avatar's URL; `None` without a custom avatar (see [`display_avatar_url`](Self::display_avatar_url))
95    pub fn avatar_url(&self) -> Option<String> {
96        self.avatar_url_with(None, None)
97    }
98
99    /// [`avatar_url`](Self::avatar_url) in `format` and `size` (see [`image_url`])
100    pub fn avatar_url_with(
101        &self,
102        format: Option<ImageFormat>,
103        size: Option<u16>,
104    ) -> Option<String> {
105        hashed(
106            self.avatar.as_deref(),
107            format_args!("avatars/{}", self.id),
108            format,
109            size,
110        )
111    }
112
113    /// The URL of the default avatar Discord shows for this user without a custom one: one of
114    /// six, picked by id (or by discriminator, for users still on the legacy username system).
115    /// Always a PNG at a fixed size.
116    pub fn default_avatar_url(&self) -> String {
117        let index = if self.discriminator == "0" {
118            (self.id.to_u64() >> 22) % 6
119        } else {
120            self.discriminator.parse::<u64>().unwrap_or(0) % 5
121        };
122        format!("{CDN_BASE}/embed/avatars/{index}.png")
123    }
124
125    /// The avatar Discord shows: the custom one's URL, else the default one's
126    pub fn display_avatar_url(&self) -> String {
127        self.display_avatar_url_with(None, None)
128    }
129
130    /// [`display_avatar_url`](Self::display_avatar_url) in `format` and `size` (see
131    /// [`image_url`]); the default avatar ignores both
132    pub fn display_avatar_url_with(
133        &self,
134        format: Option<ImageFormat>,
135        size: Option<u16>,
136    ) -> String {
137        self.avatar_url_with(format, size)
138            .unwrap_or_else(|| self.default_avatar_url())
139    }
140}
141
142impl Member {
143    /// The URL of the member's avatar in the guild `guild_id`; `None` without one
144    pub fn guild_avatar_url(&self, guild_id: Snowflake) -> Option<String> {
145        self.guild_avatar_url_with(guild_id, None, None)
146    }
147
148    /// [`guild_avatar_url`](Self::guild_avatar_url) in `format` and `size` (see [`image_url`])
149    pub fn guild_avatar_url_with(
150        &self,
151        guild_id: Snowflake,
152        format: Option<ImageFormat>,
153        size: Option<u16>,
154    ) -> Option<String> {
155        member_avatar(self.avatar.as_deref(), guild_id, self.user.id, format, size)
156    }
157
158    /// The avatar Discord shows in the guild `guild_id`: the member's guild avatar, else the
159    /// user's [`display_avatar_url`](User::display_avatar_url)
160    pub fn display_avatar_url(&self, guild_id: Snowflake) -> String {
161        self.display_avatar_url_with(guild_id, None, None)
162    }
163
164    /// [`display_avatar_url`](Self::display_avatar_url) in `format` and `size` (see
165    /// [`image_url`]); the default avatar ignores both
166    pub fn display_avatar_url_with(
167        &self,
168        guild_id: Snowflake,
169        format: Option<ImageFormat>,
170        size: Option<u16>,
171    ) -> String {
172        self.guild_avatar_url_with(guild_id, format, size)
173            .unwrap_or_else(|| self.user.display_avatar_url_with(format, size))
174    }
175}
176
177impl PartialMember {
178    /// The URL of the avatar of the member `user_id` (resolved separately) in the guild
179    /// `guild_id`; `None` without one
180    pub fn guild_avatar_url(&self, guild_id: Snowflake, user_id: Snowflake) -> Option<String> {
181        self.guild_avatar_url_with(guild_id, user_id, None, None)
182    }
183
184    /// [`guild_avatar_url`](Self::guild_avatar_url) in `format` and `size` (see [`image_url`])
185    pub fn guild_avatar_url_with(
186        &self,
187        guild_id: Snowflake,
188        user_id: Snowflake,
189        format: Option<ImageFormat>,
190        size: Option<u16>,
191    ) -> Option<String> {
192        member_avatar(self.avatar.as_deref(), guild_id, user_id, format, size)
193    }
194}
195
196fn member_avatar(
197    hash: Option<&str>,
198    guild_id: Snowflake,
199    user_id: Snowflake,
200    format: Option<ImageFormat>,
201    size: Option<u16>,
202) -> Option<String> {
203    hashed(
204        hash,
205        format_args!("guilds/{guild_id}/users/{user_id}/avatars"),
206        format,
207        size,
208    )
209}
210
211impl Guild {
212    /// The icon's URL; `None` without one
213    pub fn icon_url(&self) -> Option<String> {
214        self.icon_url_with(None, None)
215    }
216
217    /// [`icon_url`](Self::icon_url) in `format` and `size` (see [`image_url`])
218    pub fn icon_url_with(&self, format: Option<ImageFormat>, size: Option<u16>) -> Option<String> {
219        guild_icon(self.icon.as_deref(), self.id, format, size)
220    }
221
222    /// The invite splash's URL; `None` without one
223    pub fn splash_url(&self) -> Option<String> {
224        self.splash_url_with(None, None)
225    }
226
227    /// [`splash_url`](Self::splash_url) in `format` and `size` (see [`image_url`]); no GIF
228    pub fn splash_url_with(
229        &self,
230        format: Option<ImageFormat>,
231        size: Option<u16>,
232    ) -> Option<String> {
233        guild_splash(self.splash.as_deref(), self.id, format, size)
234    }
235
236    /// The discovery splash's URL; `None` without one
237    pub fn discovery_splash_url(&self) -> Option<String> {
238        self.discovery_splash_url_with(None, None)
239    }
240
241    /// [`discovery_splash_url`](Self::discovery_splash_url) in `format` and `size` (see
242    /// [`image_url`]); no GIF
243    pub fn discovery_splash_url_with(
244        &self,
245        format: Option<ImageFormat>,
246        size: Option<u16>,
247    ) -> Option<String> {
248        guild_discovery_splash(self.discovery_splash.as_deref(), self.id, format, size)
249    }
250
251    /// The banner's URL; `None` without one
252    pub fn banner_url(&self) -> Option<String> {
253        self.banner_url_with(None, None)
254    }
255
256    /// [`banner_url`](Self::banner_url) in `format` and `size` (see [`image_url`])
257    pub fn banner_url_with(
258        &self,
259        format: Option<ImageFormat>,
260        size: Option<u16>,
261    ) -> Option<String> {
262        hashed(
263            self.banner.as_deref(),
264            format_args!("banners/{}", self.id),
265            format,
266            size,
267        )
268    }
269}
270
271impl PartialGuild {
272    /// The icon's URL; `None` without one
273    pub fn icon_url(&self) -> Option<String> {
274        self.icon_url_with(None, None)
275    }
276
277    /// [`icon_url`](Self::icon_url) in `format` and `size` (see [`image_url`])
278    pub fn icon_url_with(&self, format: Option<ImageFormat>, size: Option<u16>) -> Option<String> {
279        guild_icon(self.icon.as_deref(), self.id, format, size)
280    }
281}
282
283impl GuildPreview {
284    /// The icon's URL; `None` without one
285    pub fn icon_url(&self) -> Option<String> {
286        self.icon_url_with(None, None)
287    }
288
289    /// [`icon_url`](Self::icon_url) in `format` and `size` (see [`image_url`])
290    pub fn icon_url_with(&self, format: Option<ImageFormat>, size: Option<u16>) -> Option<String> {
291        guild_icon(self.icon.as_deref(), self.id, format, size)
292    }
293
294    /// The invite splash's URL; `None` without one
295    pub fn splash_url(&self) -> Option<String> {
296        self.splash_url_with(None, None)
297    }
298
299    /// [`splash_url`](Self::splash_url) in `format` and `size` (see [`image_url`]); no GIF
300    pub fn splash_url_with(
301        &self,
302        format: Option<ImageFormat>,
303        size: Option<u16>,
304    ) -> Option<String> {
305        guild_splash(self.splash.as_deref(), self.id, format, size)
306    }
307
308    /// The discovery splash's URL; `None` without one
309    pub fn discovery_splash_url(&self) -> Option<String> {
310        self.discovery_splash_url_with(None, None)
311    }
312
313    /// [`discovery_splash_url`](Self::discovery_splash_url) in `format` and `size` (see
314    /// [`image_url`]); no GIF
315    pub fn discovery_splash_url_with(
316        &self,
317        format: Option<ImageFormat>,
318        size: Option<u16>,
319    ) -> Option<String> {
320        guild_discovery_splash(self.discovery_splash.as_deref(), self.id, format, size)
321    }
322}
323
324fn guild_icon(
325    hash: Option<&str>,
326    guild_id: Snowflake,
327    format: Option<ImageFormat>,
328    size: Option<u16>,
329) -> Option<String> {
330    hashed(hash, format_args!("icons/{guild_id}"), format, size)
331}
332
333fn guild_splash(
334    hash: Option<&str>,
335    guild_id: Snowflake,
336    format: Option<ImageFormat>,
337    size: Option<u16>,
338) -> Option<String> {
339    hashed(hash, format_args!("splashes/{guild_id}"), format, size)
340}
341
342fn guild_discovery_splash(
343    hash: Option<&str>,
344    guild_id: Snowflake,
345    format: Option<ImageFormat>,
346    size: Option<u16>,
347) -> Option<String> {
348    hashed(
349        hash,
350        format_args!("discovery-splashes/{guild_id}"),
351        format,
352        size,
353    )
354}
355
356impl Role {
357    /// The role icon's URL; `None` without one (or with a [`unicode_emoji`](Self::unicode_emoji) instead)
358    pub fn icon_url(&self) -> Option<String> {
359        self.icon_url_with(None, None)
360    }
361
362    /// [`icon_url`](Self::icon_url) in `format` and `size` (see [`image_url`]); no GIF
363    pub fn icon_url_with(&self, format: Option<ImageFormat>, size: Option<u16>) -> Option<String> {
364        hashed(
365            self.icon.as_deref(),
366            format_args!("role-icons/{}", self.id),
367            format,
368            size,
369        )
370    }
371}
372
373impl Emoji {
374    /// The custom emoji's URL: a GIF if animated, else a WebP (which Discord recommends for
375    /// emojis); `None` for unicode emojis
376    pub fn url(&self) -> Option<String> {
377        self.url_with(None, None)
378    }
379
380    /// [`url`](Self::url) in `format` and `size` (see [`image_url`])
381    pub fn url_with(&self, format: Option<ImageFormat>, size: Option<u16>) -> Option<String> {
382        emoji(self.id, self.animated, format, size)
383    }
384}
385
386impl PartialEmoji {
387    /// The custom emoji's URL: a GIF if animated, else a WebP (which Discord recommends for
388    /// emojis); `None` for unicode emojis
389    pub fn url(&self) -> Option<String> {
390        self.url_with(None, None)
391    }
392
393    /// [`url`](Self::url) in `format` and `size` (see [`image_url`])
394    pub fn url_with(&self, format: Option<ImageFormat>, size: Option<u16>) -> Option<String> {
395        emoji(self.id, self.animated, format, size)
396    }
397}
398
399fn emoji(
400    id: Option<Snowflake>,
401    animated: Option<bool>,
402    format: Option<ImageFormat>,
403    size: Option<u16>,
404) -> Option<String> {
405    let format = format.unwrap_or(if animated == Some(true) {
406        ImageFormat::Gif
407    } else {
408        ImageFormat::Webp
409    });
410    Some(image_url("emojis", &id?.to_string(), Some(format), size))
411}
412
413impl Sticker {
414    /// The sticker's URL, in its format: PNG (also for APNG), Lottie JSON, or GIF (served from
415    /// `media.discordapp.net`). Stickers come in one size.
416    pub fn url(&self) -> String {
417        sticker(self.id, self.format_type)
418    }
419}
420
421impl StickerItem {
422    /// The sticker's URL, in its format: PNG (also for APNG), Lottie JSON, or GIF (served from
423    /// `media.discordapp.net`). Stickers come in one size.
424    pub fn url(&self) -> String {
425        sticker(self.id, Some(self.format_type))
426    }
427}
428
429fn sticker(id: Snowflake, format_type: Option<i32>) -> String {
430    match format_type {
431        Some(3) => format!("{CDN_BASE}/stickers/{id}.json"),
432        Some(4) => format!("{MEDIA_BASE}/stickers/{id}.gif"),
433        _ => format!("{CDN_BASE}/stickers/{id}.png"),
434    }
435}
436
437#[cfg(test)]
438mod tests {
439    use serde_json::json;
440
441    use super::*;
442
443    fn from<T: serde::de::DeserializeOwned>(value: serde_json::Value) -> T {
444        serde_json::from_value(value).unwrap()
445    }
446
447    fn user(avatar: Option<&str>, discriminator: &str) -> User {
448        from(json!({
449            "id": "80351110224678912",
450            "username": "nelly",
451            "discriminator": discriminator,
452            "avatar": avatar,
453        }))
454    }
455
456    fn member(avatar: Option<&str>, user_avatar: Option<&str>) -> Member {
457        from(json!({
458            "user": {
459                "id": "80351110224678912",
460                "username": "nelly",
461                "discriminator": "0",
462                "avatar": user_avatar,
463            },
464            "avatar": avatar,
465            "roles": [],
466            "joined_at": "2015-04-26T06:26:56.936000+00:00",
467            "premium_since": null,
468            "deaf": false,
469            "mute": false,
470            "flags": 0,
471            "pending": null,
472            "permissions": null,
473            "communication_disabled_until": null,
474        }))
475    }
476
477    const GUILD: Snowflake = Snowflake::from_u64(197038439483310086);
478
479    #[test]
480    fn image_urls() {
481        assert_eq!(
482            image_url("avatars/1", "abc", None, None),
483            "https://cdn.discordapp.com/avatars/1/abc.png"
484        );
485        assert_eq!(
486            image_url("avatars/1", "a_abc", None, Some(256)),
487            "https://cdn.discordapp.com/avatars/1/a_abc.gif?size=256"
488        );
489        assert_eq!(
490            image_url("avatars/1", "a_abc", Some(ImageFormat::Webp), Some(64)),
491            "https://cdn.discordapp.com/avatars/1/a_abc.webp?size=64&animated=true"
492        );
493        assert_eq!(
494            image_url("avatars/1", "a_abc", Some(ImageFormat::Webp), None),
495            "https://cdn.discordapp.com/avatars/1/a_abc.webp?animated=true"
496        );
497        assert_eq!(
498            image_url("avatars/1", "abc", Some(ImageFormat::Jpeg), None),
499            "https://cdn.discordapp.com/avatars/1/abc.jpg"
500        );
501    }
502
503    #[test]
504    #[should_panic(expected = "powers of two")]
505    #[cfg(debug_assertions)]
506    fn image_sizes_are_checked() {
507        image_url("avatars/1", "abc", None, Some(100));
508    }
509
510    #[test]
511    fn user_avatars() {
512        let animated = user(Some("a_8342729096ea3675442027381ff50dfe"), "0");
513        assert_eq!(
514            animated.avatar_url().unwrap(),
515            "https://cdn.discordapp.com/avatars/80351110224678912/a_8342729096ea3675442027381ff50dfe.gif"
516        );
517        assert_eq!(
518            animated
519                .avatar_url_with(Some(ImageFormat::Png), Some(128))
520                .unwrap(),
521            "https://cdn.discordapp.com/avatars/80351110224678912/a_8342729096ea3675442027381ff50dfe.png?size=128"
522        );
523        assert_eq!(
524            animated.display_avatar_url(),
525            animated.avatar_url().unwrap()
526        );
527
528        let none = user(None, "0");
529        assert_eq!(none.avatar_url(), None);
530        assert_eq!(none.display_avatar_url(), none.default_avatar_url());
531    }
532
533    #[test]
534    fn default_avatars() {
535        // Unique usernames: (id >> 22) % 6
536        assert_eq!(
537            user(None, "0").default_avatar_url(),
538            format!(
539                "https://cdn.discordapp.com/embed/avatars/{}.png",
540                (80351110224678912u64 >> 22) % 6
541            )
542        );
543        assert_eq!(
544            user(None, "0").default_avatar_url(),
545            "https://cdn.discordapp.com/embed/avatars/5.png"
546        );
547        // Legacy discriminators: discriminator % 5
548        assert_eq!(
549            user(None, "1337").default_avatar_url(),
550            "https://cdn.discordapp.com/embed/avatars/2.png"
551        );
552    }
553
554    #[test]
555    fn member_avatars() {
556        let with = member(Some("abc"), Some("def"));
557        assert_eq!(
558            with.guild_avatar_url(GUILD).unwrap(),
559            "https://cdn.discordapp.com/guilds/197038439483310086/users/80351110224678912/avatars/abc.png"
560        );
561        assert_eq!(
562            with.display_avatar_url_with(GUILD, None, Some(32)),
563            "https://cdn.discordapp.com/guilds/197038439483310086/users/80351110224678912/avatars/abc.png?size=32"
564        );
565
566        let without = member(None, Some("def"));
567        assert_eq!(without.guild_avatar_url(GUILD), None);
568        assert_eq!(
569            without.display_avatar_url(GUILD),
570            "https://cdn.discordapp.com/avatars/80351110224678912/def.png"
571        );
572
573        let partial: PartialMember = from(json!({
574            "nick": null,
575            "avatar": "a_abc",
576            "roles": [],
577            "joined_at": "2015-04-26T06:26:56.936000+00:00",
578            "premium_since": null,
579            "pending": null,
580            "permissions": "0",
581        }));
582        assert_eq!(
583            partial
584                .guild_avatar_url(GUILD, Snowflake::from_u64(1))
585                .unwrap(),
586            "https://cdn.discordapp.com/guilds/197038439483310086/users/1/avatars/a_abc.gif"
587        );
588    }
589
590    #[test]
591    fn guild_images() {
592        let guild: Guild = from(json!({
593            "id": "197038439483310086", "name": "Testers", "icon": "a_icon",
594            "splash": "splash", "discovery_splash": "discovery", "banner": "banner",
595            "description": null, "owner_id": "2", "application_id": null, "afk_channel_id": null,
596            "afk_timeout": 300, "system_channel_id": null, "system_channel_flags": 0,
597            "widget_enabled": false, "widget_channel_id": null, "verification_level": 1,
598            "roles": [], "default_message_notifications": 1, "mfa_level": 0,
599            "explicit_content_filter": 2, "max_presences": null, "max_members": 500000,
600            "vanity_url_code": null, "premium_tier": 0, "premium_subscription_count": 0,
601            "preferred_locale": "en-US", "rules_channel_id": null,
602            "safety_alerts_channel_id": null, "public_updates_channel_id": null,
603            "premium_progress_bar_enabled": false, "nsfw_level": 0, "emojis": [],
604            "features": [],
605        }));
606        assert_eq!(
607            guild.icon_url().unwrap(),
608            "https://cdn.discordapp.com/icons/197038439483310086/a_icon.gif"
609        );
610        assert_eq!(
611            guild
612                .splash_url_with(Some(ImageFormat::Webp), Some(1024))
613                .unwrap(),
614            "https://cdn.discordapp.com/splashes/197038439483310086/splash.webp?size=1024"
615        );
616        assert_eq!(
617            guild.discovery_splash_url().unwrap(),
618            "https://cdn.discordapp.com/discovery-splashes/197038439483310086/discovery.png"
619        );
620        assert_eq!(
621            guild.banner_url().unwrap(),
622            "https://cdn.discordapp.com/banners/197038439483310086/banner.png"
623        );
624
625        let partial: PartialGuild =
626            from(json!({ "id": "197038439483310086", "name": "Testers", "icon": null }));
627        assert_eq!(partial.icon_url(), None);
628
629        let preview: GuildPreview = from(json!({
630            "id": "197038439483310086",
631            "name": "Testers",
632            "icon": "icon",
633            "splash": null,
634            "discovery_splash": "discovery",
635            "emojis": [],
636            "features": [],
637            "approximate_member_count": 1,
638            "approximate_presence_count": 1,
639            "description": null,
640        }));
641        assert_eq!(
642            preview.icon_url().unwrap(),
643            "https://cdn.discordapp.com/icons/197038439483310086/icon.png"
644        );
645        assert_eq!(preview.splash_url(), None);
646        assert_eq!(
647            preview.discovery_splash_url().unwrap(),
648            "https://cdn.discordapp.com/discovery-splashes/197038439483310086/discovery.png"
649        );
650    }
651
652    #[test]
653    fn role_icons() {
654        let role: Role = from(json!({
655            "id": "41771983423143936",
656            "name": "WE DEM BOYZZ!!!!!!",
657            "color": 3447003,
658            "hoist": true,
659            "icon": "cf3ced8600b777c9486c4ee2cd8d6e1c",
660            "unicode_emoji": null,
661            "position": 1,
662            "permissions": "66321471",
663            "managed": false,
664            "mentionable": false,
665            "flags": 0,
666        }));
667        assert_eq!(
668            role.icon_url().unwrap(),
669            "https://cdn.discordapp.com/role-icons/41771983423143936/cf3ced8600b777c9486c4ee2cd8d6e1c.png"
670        );
671    }
672
673    #[test]
674    fn emoji_urls() {
675        let custom: Emoji = from(json!({ "id": "41771983429993937", "name": "LUL" }));
676        assert_eq!(
677            custom.url().unwrap(),
678            "https://cdn.discordapp.com/emojis/41771983429993937.webp"
679        );
680        assert_eq!(
681            custom.url_with(Some(ImageFormat::Png), Some(64)).unwrap(),
682            "https://cdn.discordapp.com/emojis/41771983429993937.png?size=64"
683        );
684
685        let animated: PartialEmoji =
686            from(json!({ "id": "41771983429993937", "name": "dance", "animated": true }));
687        assert_eq!(
688            animated.url().unwrap(),
689            "https://cdn.discordapp.com/emojis/41771983429993937.gif"
690        );
691
692        let unicode: PartialEmoji = from(json!({ "id": null, "name": "🔥" }));
693        assert_eq!(unicode.url(), None);
694    }
695
696    #[test]
697    fn sticker_urls() {
698        let item = |format_type: i32| -> StickerItem {
699            from(json!({ "id": "749054660769218631", "name": "Wave", "format_type": format_type }))
700        };
701        assert_eq!(
702            item(1).url(),
703            "https://cdn.discordapp.com/stickers/749054660769218631.png"
704        );
705        assert_eq!(
706            item(2).url(),
707            "https://cdn.discordapp.com/stickers/749054660769218631.png"
708        );
709        assert_eq!(
710            item(3).url(),
711            "https://cdn.discordapp.com/stickers/749054660769218631.json"
712        );
713        assert_eq!(
714            item(4).url(),
715            "https://media.discordapp.net/stickers/749054660769218631.gif"
716        );
717
718        let sticker: Sticker = from(json!({
719            "id": "749054660769218631",
720            "name": "Wave",
721            "tags": "wave",
722            "type": 1,
723            "format_type": 3,
724        }));
725        assert_eq!(
726            sticker.url(),
727            "https://cdn.discordapp.com/stickers/749054660769218631.json"
728        );
729    }
730}