1use 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
12pub const CDN_BASE: &str = "https://cdn.discordapp.com";
14
15const MEDIA_BASE: &str = "https://media.discordapp.net";
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum ImageFormat {
21 Png,
23 Jpeg,
25 Webp,
27 Gif,
29}
30
31impl ImageFormat {
32 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 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
57pub 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
83fn 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 pub fn avatar_url(&self) -> Option<String> {
96 self.avatar_url_with(None, None)
97 }
98
99 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 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 pub fn display_avatar_url(&self) -> String {
127 self.display_avatar_url_with(None, None)
128 }
129
130 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 pub fn guild_avatar_url(&self, guild_id: Snowflake) -> Option<String> {
145 self.guild_avatar_url_with(guild_id, None, None)
146 }
147
148 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 pub fn display_avatar_url(&self, guild_id: Snowflake) -> String {
161 self.display_avatar_url_with(guild_id, None, None)
162 }
163
164 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 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 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 pub fn icon_url(&self) -> Option<String> {
214 self.icon_url_with(None, None)
215 }
216
217 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 pub fn splash_url(&self) -> Option<String> {
224 self.splash_url_with(None, None)
225 }
226
227 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 pub fn discovery_splash_url(&self) -> Option<String> {
238 self.discovery_splash_url_with(None, None)
239 }
240
241 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 pub fn banner_url(&self) -> Option<String> {
253 self.banner_url_with(None, None)
254 }
255
256 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 pub fn icon_url(&self) -> Option<String> {
274 self.icon_url_with(None, None)
275 }
276
277 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 pub fn icon_url(&self) -> Option<String> {
286 self.icon_url_with(None, None)
287 }
288
289 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 pub fn splash_url(&self) -> Option<String> {
296 self.splash_url_with(None, None)
297 }
298
299 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 pub fn discovery_splash_url(&self) -> Option<String> {
310 self.discovery_splash_url_with(None, None)
311 }
312
313 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 pub fn icon_url(&self) -> Option<String> {
359 self.icon_url_with(None, None)
360 }
361
362 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 pub fn url(&self) -> Option<String> {
377 self.url_with(None, None)
378 }
379
380 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 pub fn url(&self) -> Option<String> {
390 self.url_with(None, None)
391 }
392
393 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 pub fn url(&self) -> String {
417 sticker(self.id, self.format_type)
418 }
419}
420
421impl StickerItem {
422 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 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 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}