diff --git a/Cargo.lock b/Cargo.lock index 3003383..3d7601a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1235,6 +1235,7 @@ dependencies = [ "rand", "redis", "redis-macros", + "regex", "reqwest", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index a7d48aa..d38897d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ once_cell = "1.18.0" rand = "0.8.5" redis = { version = "0.23.3", features = ["tokio-comp", "tokio-rustls-comp"] } redis-macros = "0.2.1" +regex = "1.10.2" reqwest = { version = "0.11.22", default-features = false, features = [ "rustls-tls", "json", diff --git a/src/handlers/event/expand_link.rs b/src/handlers/event/expand_link.rs new file mode 100644 index 0000000..95065e1 --- /dev/null +++ b/src/handlers/event/expand_link.rs @@ -0,0 +1,28 @@ +use color_eyre::eyre::{eyre, Context as _, Result}; +use poise::serenity_prelude::{Context, Message}; + +use crate::utils; + +pub async fn handle(ctx: &Context, msg: &Message) -> Result<()> { + let embeds = utils::resolve_message(ctx, msg).await?; + + // TOOD getchoo: actually reply to user + // ...not sure why Message doesn't give me a builder in reply() or equivalents + let our_channel = msg + .channel(ctx) + .await + .wrap_err_with(|| "Couldn't get channel from message!")? + .guild() + .ok_or_else(|| eyre!("Couldn't convert to GuildChannel!"))?; + + if !embeds.is_empty() { + our_channel + .send_message(ctx, |m| { + m.set_embeds(embeds) + .allowed_mentions(|am| am.replied_user(false)) + }) + .await?; + } + + Ok(()) +} diff --git a/src/handlers/event/mod.rs b/src/handlers/event/mod.rs index ad63d66..b953eae 100644 --- a/src/handlers/event/mod.rs +++ b/src/handlers/event/mod.rs @@ -6,6 +6,7 @@ use poise::{Event, FrameworkContext}; mod delete; mod eta; +mod expand_link; mod support_onboard; pub async fn handle( @@ -26,7 +27,8 @@ pub async fn handle( return Ok(()); } - eta::handle(ctx, new_message).await? + eta::handle(ctx, new_message).await?; + expand_link::handle(ctx, new_message).await?; } Event::ReactionAdd { add_reaction } => delete::handle(ctx, add_reaction).await?, diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 800865d..700ce3d 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,13 +1,11 @@ -use crate::Context; - use color_eyre::eyre::{eyre, Result}; -use poise::serenity_prelude as serenity; use rand::seq::SliceRandom; -use serenity::{CreateEmbed, Message}; -use url::Url; #[macro_use] mod macros; +mod resolve_message; + +pub use resolve_message::resolve as resolve_message; /* * chooses a random element from an array @@ -20,91 +18,3 @@ pub fn random_choice(arr: [&str; N]) -> Result { Ok((*resp).to_string()) } - -// waiting for `round_char_boundary` to stabilize -pub fn floor_char_boundary(s: &str, index: usize) -> usize { - if index >= s.len() { - s.len() - } else { - let lower_bound = index.saturating_sub(3); - let new_index = s.as_bytes()[lower_bound..=index] - .iter() - .rposition(|&b| (b as i8) >= -0x40); // b.is_utf8_char_boundary - - // Can be made unsafe but whatever - lower_bound + new_index.unwrap() - } -} - -pub async fn send_url_as_embed(ctx: Context<'_>, url: String) -> Result<()> { - let parsed = Url::parse(&url)?; - - let title = parsed - .path_segments() - .unwrap() - .last() - .unwrap_or("image") - .replace("%20", " "); - - ctx.send(|c| c.embed(|e| e.title(title).image(&url).url(url))) - .await?; - - Ok(()) -} - -pub async fn resolve_message_to_embed(ctx: &serenity::Context, msg: &Message) -> CreateEmbed { - let truncation_point = floor_char_boundary(&msg.content, 700); - let truncated_content = if msg.content.len() <= truncation_point { - msg.content.to_string() - } else { - format!("{}...", &msg.content[..truncation_point]) - }; - - let color = msg - .member(ctx) - .await - .ok() - .and_then(|m| m.highest_role_info(&ctx.cache)) - .and_then(|(role, _)| role.to_role_cached(&ctx.cache)) - .map(|role| role.colour); - - let attached_image = msg - .attachments - .iter() - .filter(|a| { - a.content_type - .as_ref() - .filter(|ct| ct.contains("image/")) - .is_some() - }) - .map(|a| &a.url) - .next(); - - let attachments_len = msg.attachments.len(); - - let mut embed = msg - .embeds - .first() - .map(|embed| CreateEmbed::from(embed.clone())) - .unwrap_or_default(); - - embed.author(|author| author.name(&msg.author.name).icon_url(&msg.author.face())); - - if let Some(color) = color { - embed.color(color); - } - - if let Some(attachment) = attached_image { - embed.image(attachment); - } - - if attachments_len > 1 { - embed.footer(|footer| { - // yes it will say '1 attachments' no i do not care - footer.text(format!("{} attachments", attachments_len)) - }); - } - - embed.description(truncated_content); - embed -} diff --git a/src/utils/resolve_message.rs b/src/utils/resolve_message.rs new file mode 100644 index 0000000..cbf7a82 --- /dev/null +++ b/src/utils/resolve_message.rs @@ -0,0 +1,124 @@ +use color_eyre::eyre::{eyre, Context as _, Result}; +use log::*; +use once_cell::sync::Lazy; +use poise::serenity_prelude::{ChannelType, Colour, Context, CreateEmbed, Message}; +use regex::Regex; + +static MESSAGE_PATTERN: Lazy = Lazy::new(|| { + Regex::new(r"/(https?:\/\/)?(?:canary\.|ptb\.)?discord(?:app)?\.com\/channels\/(?\d+)\/(?\d+)\/(?\d+)/g;").unwrap() +}); + +pub fn find_first_image(msg: &Message) -> Option { + msg.attachments + .iter() + .find(|a| { + a.content_type + .as_ref() + .unwrap_or(&"".to_string()) + .starts_with("image/") + }) + .map(|res| res.url.clone()) +} + +pub async fn resolve(ctx: &Context, msg: &Message) -> Result> { + let matches = MESSAGE_PATTERN.captures_iter(&msg.content); + let mut embeds: Vec = vec![]; + + for captured in matches.take(3) { + // don't leak messages from other servers + if let Some(server_id) = captured.get(0) { + let other_server: u64 = server_id.as_str().parse().unwrap_or_default(); + let current_id = msg.guild_id.unwrap_or_default(); + + if &other_server != current_id.as_u64() { + debug!("Not resolving message of other guild."); + continue; + } + } else { + warn!("Couldn't find server_id from Discord link! Not resolving message to be safe"); + continue; + } + + if let Some(channel_id) = captured.get(1) { + let parsed: u64 = channel_id.as_str().parse().unwrap_or_default(); + let req_channel = ctx + .cache + .channel(parsed) + .ok_or_else(|| eyre!("Couldn't get channel_id from Discord regex!"))? + .guild() + .ok_or_else(|| { + eyre!("Couldn't convert to GuildChannel from channel_id {parsed}!") + })?; + + if !req_channel.is_text_based() { + debug!("Not resolving message is non-text-based channel."); + continue; + } + + if req_channel.kind == ChannelType::PrivateThread { + if let Some(id) = req_channel.parent_id { + let parent = ctx.cache.guild_channel(id).ok_or_else(|| { + eyre!("Couldn't get parent channel {id} for thread {req_channel}!") + })?; + let parent_members = parent.members(ctx).await.unwrap_or_default(); + + if !parent_members.iter().any(|m| m.user.id == msg.author.id) { + debug!("Not resolving message for user not a part of a private thread."); + continue; + } + } + } else if req_channel + .members(ctx) + .await? + .iter() + .any(|m| m.user.id == msg.author.id) + { + debug!("Not resolving for message for user not a part of a channel"); + continue; + } + + let message_id: u64 = captured + .get(2) + .ok_or_else(|| eyre!("Couldn't get message_id from Discord regex!"))? + .as_str() + .parse() + .wrap_err_with(|| { + eyre!("Couldn't parse message_id from Discord regex as a MessageId!") + })?; + + let original_message = req_channel.message(ctx, message_id).await?; + let mut embed = CreateEmbed::default(); + embed + .author(|a| { + a.name(original_message.author.tag()) + .icon_url(original_message.author.default_avatar_url()) + }) + .color(Colour::BLITZ_BLUE) + .timestamp(original_message.timestamp) + .footer(|f| f.text(format!("#{}", req_channel.name))) + .description(format!( + "{}\n\n[Jump to original message]({})", + original_message.content, + original_message.link() + )); + + if !original_message.attachments.is_empty() { + embed.fields(original_message.attachments.iter().map(|a| { + ( + "Attachments".to_string(), + format!("[{}]({})", a.filename, a.url), + false, + ) + })); + + if let Some(image) = find_first_image(msg) { + embed.image(image); + } + } + + embeds.push(embed); + } + } + + Ok(embeds) +}