1use regex::Regex;
9use serenity::all::{
10 ChannelId, ChannelType, Context, GuildChannel, GuildId, Message, MessageId,
11 PermissionOverwrite, PermissionOverwriteType, Permissions, RoleId,
12};
13use serenity_builder::model::embed::SerenityEmbed;
14use std::collections::{HashMap, HashSet};
15use std::sync::LazyLock;
16
17use super::{ExpandContext, ExpandError, ExpandedContent, LinkExpander};
18use crate::cache::CacheArgs;
19use crate::config::BabyriteConfig;
20use serenity::futures::future::join_all;
21
22pub static MESSAGE_LINK_REGEX: LazyLock<Regex> = LazyLock::new(|| {
26 Regex::new(r"https://(?:ptb\.|canary\.)?discord\.com/channels/(\d+)/(\d+)/(\d+)").unwrap()
27});
28
29#[derive(Debug)]
31pub struct MessageLinkIDs {
32 pub guild_id: GuildId,
34 pub channel_id: ChannelId,
36 pub message_id: MessageId,
38}
39
40#[derive(Debug)]
42pub struct Preview {
43 pub message: Message,
45 pub channel: GuildChannel,
47}
48
49pub struct DiscordExpander;
51
52#[serenity::async_trait]
53impl LinkExpander for DiscordExpander {
54 fn enabled(&self, _config: &BabyriteConfig) -> bool {
56 true
57 }
58
59 #[cfg_attr(coverage_nightly, coverage(off))]
66 async fn expand_all(&self, cx: &ExpandContext<'_>) -> Vec<ExpandedContent> {
67 let links = MessageLinkIDs::parse_all(&cx.message.content);
68 if links.is_empty() {
69 return Vec::new();
70 }
71 tracing::debug!(count = links.len(), "parsed Discord links");
72
73 let links: Vec<_> = links
74 .into_iter()
75 .filter(|ids| {
76 if ids.guild_id != cx.guild_id {
77 tracing::debug!(
78 link_guild_id = %ids.guild_id,
79 "skipping cross-guild Discord link"
80 );
81 return false;
82 }
83 true
84 })
85 .collect();
86 if links.is_empty() {
87 return Vec::new();
88 }
89
90 let source_channel = match (CacheArgs {
91 guild_id: cx.guild_id,
92 channel_id: cx.message.channel_id,
93 })
94 .get(cx.ctx)
95 .await
96 {
97 Ok(channel) => channel,
98 Err(e) => {
99 tracing::error!(error = %e, "failed to resolve source channel");
100 return Vec::new();
101 }
102 };
103
104 join_all(links.iter().map(|ids| ids.fetch(cx.ctx, &source_channel)))
105 .await
106 .into_iter()
107 .filter_map(|result| match result {
108 Ok(content) => Some(content),
109 Err(e) => {
110 tracing::error!(error = %e, "failed to expand Discord link");
111 None
112 }
113 })
114 .collect()
115 }
116}
117
118#[derive(thiserror::Error, Debug)]
120pub enum PreviewError {
121 #[error("Failed to retrieve from cache.")]
123 Cache,
124 #[error("NSFW content previews are not permitted, but the channel is marked as NSFW.")]
126 Nsfw,
127 #[error("The channel is a private channel or private thread.")]
129 Permission,
130 #[allow(clippy::enum_variant_names)]
132 #[error(transparent)]
133 SerenityError(#[from] serenity::Error),
134}
135
136impl MessageLinkIDs {
137 pub fn parse_all(text: &str) -> Vec<MessageLinkIDs> {
143 super::parse_links(text, &MESSAGE_LINK_REGEX, |captures| {
144 Some(MessageLinkIDs {
145 guild_id: GuildId::new(captures.get(1)?.as_str().parse().ok()?),
146 channel_id: ChannelId::new(captures.get(2)?.as_str().parse().ok()?),
147 message_id: MessageId::new(captures.get(3)?.as_str().parse().ok()?),
148 })
149 })
150 }
151
152 #[cfg_attr(coverage_nightly, coverage(off))]
158 #[tracing::instrument(
159 skip(self, ctx, source_channel),
160 fields(
161 guild_id = %self.guild_id,
162 channel_id = %self.channel_id,
163 message_id = %self.message_id,
164 )
165 )]
166 pub async fn fetch(
167 &self,
168 ctx: &Context,
169 source_channel: &GuildChannel,
170 ) -> Result<ExpandedContent, ExpandError> {
171 let Preview { message, channel } = Preview::get(self, ctx, source_channel).await?;
172
173 let author_icon_url = message.author.avatar_url().unwrap_or_default();
174 let embed = SerenityEmbed::builder()
175 .description(message.content)
176 .author_name(message.author.name)
177 .author_icon_url(author_icon_url)
178 .footer_text(channel.name)
179 .timestamp(message.timestamp)
180 .color(0x7A4AFFu32)
181 .image_url(
182 message
183 .attachments
184 .first()
185 .map(|a| a.url.clone())
186 .unwrap_or_default(),
187 )
188 .build();
189
190 Ok(ExpandedContent::Embed(Box::new(embed)))
191 }
192}
193
194fn is_thread(kind: ChannelType) -> bool {
200 matches!(
201 kind,
202 ChannelType::NewsThread | ChannelType::PublicThread | ChannelType::PrivateThread
203 )
204}
205
206fn has_member_view_deny(overwrites: &[PermissionOverwrite]) -> bool {
211 overwrites.iter().any(|ow| {
212 matches!(ow.kind, PermissionOverwriteType::Member(_))
213 && ow.deny.contains(Permissions::VIEW_CHANNEL)
214 })
215}
216
217fn requires_visibility_check(target: ChannelId, source: ChannelId) -> bool {
225 target != source
226}
227
228fn viewing_roles(
236 overwrites: &[PermissionOverwrite],
237 role_perms: &HashMap<RoleId, Permissions>,
238 everyone_role_id: RoleId,
239) -> HashSet<RoleId> {
240 let everyone_base = role_perms
241 .get(&everyone_role_id)
242 .copied()
243 .unwrap_or_else(Permissions::empty);
244
245 let overwrite_by_role: HashMap<RoleId, (Permissions, Permissions)> = overwrites
246 .iter()
247 .filter_map(|ow| match ow.kind {
248 PermissionOverwriteType::Role(id) => Some((id, (ow.allow, ow.deny))),
249 _ => None,
250 })
251 .collect();
252
253 let mut set = HashSet::new();
254 for (&role_id, &perms) in role_perms {
255 let base = everyone_base | perms;
256 if base.contains(Permissions::ADMINISTRATOR) {
257 set.insert(role_id);
258 continue;
259 }
260
261 let mut allowed = base.contains(Permissions::VIEW_CHANNEL);
262 for target in [everyone_role_id, role_id] {
263 if let Some(&(allow, deny)) = overwrite_by_role.get(&target) {
264 if deny.contains(Permissions::VIEW_CHANNEL) {
265 allowed = false;
266 }
267 if allow.contains(Permissions::VIEW_CHANNEL) {
268 allowed = true;
269 }
270 }
271 }
272
273 if allowed {
274 set.insert(role_id);
275 }
276 }
277 set
278}
279
280#[cfg_attr(coverage_nightly, coverage(off))]
286async fn permission_channel(
287 channel: &GuildChannel,
288 ctx: &Context,
289) -> Result<GuildChannel, PreviewError> {
290 if !is_thread(channel.kind) {
291 return Ok(channel.clone());
292 }
293
294 let parent_id = channel.parent_id.ok_or(PreviewError::Permission)?;
295 CacheArgs {
296 guild_id: channel.guild_id,
297 channel_id: parent_id,
298 }
299 .get(ctx)
300 .await
301 .map_err(|_| PreviewError::Cache)
302}
303
304#[cfg_attr(coverage_nightly, coverage(off))]
310async fn check_visibility(
311 channel: &GuildChannel,
312 source_channel: &GuildChannel,
313 guild_id: GuildId,
314 ctx: &Context,
315) -> Result<(), PreviewError> {
316 if matches!(
321 channel.kind,
322 ChannelType::PrivateThread | ChannelType::Private
323 ) {
324 tracing::debug!(kind = ?channel.kind, "rejected: private channel or thread");
325 return Err(PreviewError::Permission);
326 }
327
328 let (dest_perm, source_perm) = tokio::try_join!(
332 permission_channel(channel, ctx),
333 permission_channel(source_channel, ctx),
334 )?;
335
336 if has_member_view_deny(&dest_perm.permission_overwrites) {
339 tracing::debug!("rejected: target has a per-member VIEW_CHANNEL deny");
340 return Err(PreviewError::Permission);
341 }
342
343 let everyone_role_id = RoleId::new(guild_id.get());
344 let role_perms: HashMap<RoleId, Permissions> = {
348 let guild = ctx.cache.guild(guild_id).ok_or(PreviewError::Permission)?;
349 guild
350 .roles
351 .iter()
352 .map(|(&id, role)| (id, role.permissions))
353 .collect()
354 };
355
356 let dest_roles = viewing_roles(
357 &dest_perm.permission_overwrites,
358 &role_perms,
359 everyone_role_id,
360 );
361 let source_roles = viewing_roles(
362 &source_perm.permission_overwrites,
363 &role_perms,
364 everyone_role_id,
365 );
366 if !source_roles.is_subset(&dest_roles) {
367 tracing::debug!(
368 source_roles = source_roles.len(),
369 dest_roles = dest_roles.len(),
370 "rejected: source channel is more visible than the target"
371 );
372 return Err(PreviewError::Permission);
373 }
374
375 Ok(())
376}
377
378impl Preview {
379 #[cfg_attr(coverage_nightly, coverage(off))]
393 #[tracing::instrument(
394 skip(args, ctx, source_channel),
395 fields(
396 guild_id = %args.guild_id,
397 channel_id = %args.channel_id,
398 message_id = %args.message_id,
399 )
400 )]
401 async fn get(
402 args: &MessageLinkIDs,
403 ctx: &Context,
404 source_channel: &GuildChannel,
405 ) -> Result<Preview, PreviewError> {
406 let caches = CacheArgs {
407 guild_id: args.guild_id,
408 channel_id: args.channel_id,
409 };
410
411 let channel = caches.get(ctx).await.map_err(|_| PreviewError::Cache)?;
412 tracing::debug!(kind = ?channel.kind, nsfw = channel.nsfw, "resolved target channel");
413
414 if channel.nsfw {
415 tracing::debug!("rejected: target channel is NSFW");
416 return Err(PreviewError::Nsfw);
417 }
418
419 if requires_visibility_check(args.channel_id, source_channel.id) {
426 check_visibility(&channel, source_channel, args.guild_id, ctx).await?;
427 }
428
429 let started = std::time::Instant::now();
430 let message = channel.message(&ctx.http, args.message_id).await?;
431 tracing::debug!(
432 elapsed_ms = started.elapsed().as_millis(),
433 "fetched linked message"
434 );
435 Ok(Preview { message, channel })
436 }
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442
443 #[test]
444 fn parse_standard_link() {
445 let text = "https://discord.com/channels/123456789/987654321/111111111";
446 let results = MessageLinkIDs::parse_all(text);
447 assert_eq!(results.len(), 1);
448 assert_eq!(results[0].guild_id, GuildId::new(123456789));
449 assert_eq!(results[0].channel_id, ChannelId::new(987654321));
450 assert_eq!(results[0].message_id, MessageId::new(111111111));
451 }
452
453 #[test]
454 fn parse_ptb_link() {
455 let text = "https://ptb.discord.com/channels/123/456/789";
456 let results = MessageLinkIDs::parse_all(text);
457 assert_eq!(results.len(), 1);
458 assert_eq!(results[0].guild_id, GuildId::new(123));
459 }
460
461 #[test]
462 fn parse_canary_link() {
463 let text = "https://canary.discord.com/channels/123/456/789";
464 let results = MessageLinkIDs::parse_all(text);
465 assert_eq!(results.len(), 1);
466 assert_eq!(results[0].guild_id, GuildId::new(123));
467 }
468
469 #[test]
470 fn parse_multiple_links() {
471 let text = "https://discord.com/channels/1/2/3 and https://discord.com/channels/4/5/6";
472 let results = MessageLinkIDs::parse_all(text);
473 assert_eq!(results.len(), 2);
474 assert_eq!(results[0].guild_id, GuildId::new(1));
475 assert_eq!(results[1].guild_id, GuildId::new(4));
476 }
477
478 #[test]
479 fn parse_deduplicates() {
480 let text = "https://discord.com/channels/1/2/3 https://discord.com/channels/1/2/3";
481 let results = MessageLinkIDs::parse_all(text);
482 assert_eq!(results.len(), 1);
483 }
484
485 #[test]
486 fn parse_limits_to_three() {
487 let text = "\
488 https://discord.com/channels/1/2/3 \
489 https://discord.com/channels/4/5/6 \
490 https://discord.com/channels/7/8/9 \
491 https://discord.com/channels/10/11/12";
492 let results = MessageLinkIDs::parse_all(text);
493 assert_eq!(results.len(), 3);
494 }
495
496 #[test]
497 fn parse_no_match() {
498 let text = "Just some regular text";
499 let results = MessageLinkIDs::parse_all(text);
500 assert!(results.is_empty());
501 }
502
503 #[test]
504 fn parse_ignores_invalid_url() {
505 let text = "https://notdiscord.com/channels/1/2/3";
507 let results = MessageLinkIDs::parse_all(text);
508 assert!(results.is_empty());
509 }
510
511 #[test]
512 fn parse_ignores_angle_bracket_link() {
513 let text = "<https://discord.com/channels/123/456/789>";
514 let results = MessageLinkIDs::parse_all(text);
515 assert!(results.is_empty());
516 }
517
518 #[test]
519 fn parse_mixed_with_text() {
520 let text = "Hey check this out https://discord.com/channels/1/2/3 pretty cool right?";
521 let results = MessageLinkIDs::parse_all(text);
522 assert_eq!(results.len(), 1);
523 assert_eq!(results[0].message_id, MessageId::new(3));
524 }
525
526 use serenity::all::UserId;
529
530 fn role_ow(id: u64, allow_view: bool, deny_view: bool) -> PermissionOverwrite {
532 PermissionOverwrite {
533 allow: if allow_view {
534 Permissions::VIEW_CHANNEL
535 } else {
536 Permissions::empty()
537 },
538 deny: if deny_view {
539 Permissions::VIEW_CHANNEL
540 } else {
541 Permissions::empty()
542 },
543 kind: PermissionOverwriteType::Role(RoleId::new(id)),
544 }
545 }
546
547 fn member_ow(id: u64, deny_view: bool) -> PermissionOverwrite {
549 PermissionOverwrite {
550 allow: Permissions::empty(),
551 deny: if deny_view {
552 Permissions::VIEW_CHANNEL
553 } else {
554 Permissions::empty()
555 },
556 kind: PermissionOverwriteType::Member(UserId::new(id)),
557 }
558 }
559
560 const EVERYONE: RoleId = RoleId::new(1);
561 const MEMBER: RoleId = RoleId::new(100);
562 const SPECIAL: RoleId = RoleId::new(200);
563 const ADMIN: RoleId = RoleId::new(300);
564
565 #[test]
566 fn thread_kinds_detected() {
567 assert!(is_thread(ChannelType::PublicThread));
568 assert!(is_thread(ChannelType::NewsThread));
569 assert!(is_thread(ChannelType::PrivateThread));
570 assert!(!is_thread(ChannelType::Text));
571 assert!(!is_thread(ChannelType::Voice));
572 }
573
574 #[test]
575 fn member_view_deny_detected() {
576 assert!(has_member_view_deny(&[member_ow(5, true)]));
577 assert!(!has_member_view_deny(&[role_ow(1, false, true)]));
579 assert!(!has_member_view_deny(&[member_ow(5, false)]));
581 assert!(!has_member_view_deny(&[]));
582 }
583
584 #[test]
585 fn same_channel_skips_visibility_check() {
586 let chan = ChannelId::new(42);
587 assert!(!requires_visibility_check(chan, chan));
589 assert!(requires_visibility_check(chan, ChannelId::new(99)));
591 }
592
593 #[test]
594 fn public_channels_are_subsets() {
595 let mut roles = HashMap::new();
597 roles.insert(EVERYONE, Permissions::VIEW_CHANNEL);
598 roles.insert(MEMBER, Permissions::empty());
599
600 let viewing = viewing_roles(&[], &roles, EVERYONE);
601 assert!(viewing.contains(&EVERYONE));
602 assert!(viewing.contains(&MEMBER));
603 assert!(viewing.is_subset(&viewing));
605 }
606
607 #[test]
608 fn role_gate_allows_matching_member_role() {
609 let mut roles = HashMap::new();
611 roles.insert(EVERYONE, Permissions::empty());
612 roles.insert(MEMBER, Permissions::empty());
613
614 let ow = [
615 role_ow(EVERYONE.get(), false, true),
616 role_ow(MEMBER.get(), true, false),
617 ];
618 let viewing = viewing_roles(&ow, &roles, EVERYONE);
619
620 assert!(!viewing.contains(&EVERYONE));
621 assert!(viewing.contains(&MEMBER));
622
623 let source = viewing_roles(&ow, &roles, EVERYONE);
625 assert!(source.is_subset(&viewing));
626 }
627
628 #[test]
629 fn narrower_target_is_rejected() {
630 let mut roles = HashMap::new();
631 roles.insert(EVERYONE, Permissions::empty());
632 roles.insert(MEMBER, Permissions::empty());
633 roles.insert(SPECIAL, Permissions::empty());
634
635 let source_ow = [
637 role_ow(EVERYONE.get(), false, true),
638 role_ow(MEMBER.get(), true, false),
639 ];
640 let source = viewing_roles(&source_ow, &roles, EVERYONE);
641
642 let dest_ow = [
644 role_ow(EVERYONE.get(), false, true),
645 role_ow(SPECIAL.get(), true, false),
646 ];
647 let dest = viewing_roles(&dest_ow, &roles, EVERYONE);
648
649 assert!(source.contains(&MEMBER));
650 assert!(!dest.contains(&MEMBER));
651 assert!(!source.is_subset(&dest));
653 }
654
655 #[test]
656 fn administrator_always_views() {
657 let mut roles = HashMap::new();
658 roles.insert(EVERYONE, Permissions::empty());
659 roles.insert(ADMIN, Permissions::ADMINISTRATOR);
660
661 let ow = [role_ow(EVERYONE.get(), false, true)];
663 let viewing = viewing_roles(&ow, &roles, EVERYONE);
664 assert!(viewing.contains(&ADMIN));
665 assert!(!viewing.contains(&EVERYONE));
666 }
667
668 #[test]
669 fn other_role_overwrite_does_not_affect_role() {
670 let mut roles = HashMap::new();
671 roles.insert(EVERYONE, Permissions::VIEW_CHANNEL);
672 roles.insert(MEMBER, Permissions::empty());
673 roles.insert(SPECIAL, Permissions::empty());
674
675 let ow = [role_ow(SPECIAL.get(), false, true)];
677 let viewing = viewing_roles(&ow, &roles, EVERYONE);
678 assert!(viewing.contains(&MEMBER));
679 assert!(!viewing.contains(&SPECIAL));
680 }
681
682 #[test]
683 fn missing_everyone_role_defaults_to_no_base() {
684 let mut roles = HashMap::new();
687 roles.insert(MEMBER, Permissions::empty());
688
689 let viewing = viewing_roles(&[], &roles, EVERYONE);
690 assert!(!viewing.contains(&MEMBER));
691
692 let ow = [role_ow(MEMBER.get(), true, false)];
694 let viewing = viewing_roles(&ow, &roles, EVERYONE);
695 assert!(viewing.contains(&MEMBER));
696 }
697}