diff --git a/Cargo.lock b/Cargo.lock index f16655e..491f1e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1744,6 +1744,7 @@ dependencies = [ "serde", "serde_json", "serenity", + "to-arraystring", "tokio", ] @@ -1831,6 +1832,17 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "to-arraystring" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "340b46242bcb0b5c24768ca5901c51f0b3439ed88d04e8870f83826b960760b4" +dependencies = [ + "arrayvec", + "itoa", + "ryu", +] + [[package]] name = "tokio" version = "1.36.0" diff --git a/Cargo.toml b/Cargo.toml index d9e5895..bce738c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,3 +21,4 @@ regex = "1.10.2" octocrab = "0.19.0" reqwest = "0.11.22" hex = "0.4.3" +to-arraystring = "0.1.3" diff --git a/src/commands/snippets.rs b/src/commands/snippets.rs index 0f634dd..25c8e37 100644 --- a/src/commands/snippets.rs +++ b/src/commands/snippets.rs @@ -21,12 +21,13 @@ async fn autocomplete_snippet<'a>( .unwrap() .snippets .iter() - .map(|s| format!("{}: {}", s.id, s.title)) + .map(Snippet::format_output) .collect() }; - futures::stream::iter(snippet_list) - .filter(move |name| futures::future::ready(name.contains(partial))) + futures::stream::iter(snippet_list).filter(move |name| { + futures::future::ready(name.to_lowercase().contains(&partial.to_lowercase())) + }) } /// Show a snippet @@ -87,14 +88,6 @@ pub async fn create_snippet( rwlock_guard.snippets.push(snippet); rwlock_guard.write(); - if rwlock_guard.snippets.len() > 25 { - embed = embed.field( - "Warning", - "There are more than 25 snippets, some may not appear in the snippet list.", - false, - ); - } - embed }; diff --git a/src/commands/utils.rs b/src/commands/utils.rs index 9f5617b..1e26275 100644 --- a/src/commands/utils.rs +++ b/src/commands/utils.rs @@ -124,9 +124,7 @@ pub async fn embed( Ok(()) } -/// Create an embed in the current channel. -/// -/// +/// Edits an embed already sent by the embed command. #[allow(clippy::too_many_arguments)] #[poise::command(rename = "edit-embed", slash_command, guild_only)] pub async fn edit_embed( diff --git a/src/events/code.rs b/src/events/code.rs index 9b89d3c..baf4463 100644 --- a/src/events/code.rs +++ b/src/events/code.rs @@ -6,6 +6,7 @@ use poise::serenity_prelude::{self as serenity, Colour, Context, CreateEmbed, Me use crate::formatting::trim_indent; +// A shade of purple. const ACCENT_COLOUR: Colour = Colour::new(0x8957e5); pub async fn message(ctx: &Context, message: &Message) { diff --git a/src/events/issue.rs b/src/events/issue.rs deleted file mode 100644 index 22af6a4..0000000 --- a/src/events/issue.rs +++ /dev/null @@ -1,321 +0,0 @@ -use std::time::Duration; - -use crate::{commands::interaction_err, structures::Embeddable, Data}; -use ::serenity::builder::CreateEmbedAuthor; -use octocrab::models::issues::Issue; -use octocrab::models::pulls::PullRequest; -use poise::serenity_prelude::{ - self as serenity, ButtonStyle, Colour, Context, CreateActionRow, CreateButton, CreateEmbed, - CreateInteractionResponse, Message, Permissions, -}; -use regex::Regex; - -const DEFAULT_REPO_OWNER: &str = "OpenTabletDriver"; -const DEFAULT_REPO_NAME: &str = "OpenTabletDriver"; - -const OPEN_COLOUR: Colour = Colour::new(0x238636); -const RESOLVED_COLOUR: Colour = Colour::new(0x8957e5); -const CLOSED_COLOUR: Colour = Colour::new(0xda3633); - -pub async fn message(data: &Data, ctx: &Context, message: &Message) { - if let Some(embeds) = issue_embeds(data, message).await { - let typing = message.channel_id.start_typing(&ctx.http); - - let ctx_id = message.id.get(); // poise context isn't available here. - let remove_id = format!("{ctx_id}remove"); - let hide_body_id = format!("{ctx_id}hide_body"); - let remove = CreateActionRow::Buttons(vec![CreateButton::new(&remove_id) - .label("delete") - .style(ButtonStyle::Danger)]); - - let components = serenity::CreateActionRow::Buttons(vec![ - CreateButton::new(&remove_id) - .label("delete") - .style(ButtonStyle::Danger), - CreateButton::new(&hide_body_id).label("hide body"), - ]); - - let content: serenity::CreateMessage = serenity::CreateMessage::default() - .embeds(embeds) - .reference_message(message) - .components(vec![components]); - let msg_result = message.channel_id.send_message(ctx, content).await; - typing.stop(); - - let mut msg_deleted = false; - let mut body_hid = false; - while let Some(press) = serenity::ComponentInteractionCollector::new(ctx) - .filter(move |press| press.data.custom_id.starts_with(&ctx_id.to_string())) - .timeout(Duration::from_secs(60)) - .await - { - let has_perms = press.member.as_ref().map_or(false, |member| { - member.permissions.map_or(false, |member_perms| { - member_perms.contains(Permissions::MANAGE_MESSAGES) - }) - }); - - if press.data.custom_id == remove_id { - if press.user.id == message.author.id || has_perms { - let _ = press - .create_response(ctx, CreateInteractionResponse::Acknowledge) - .await; - if let Ok(ref msg) = msg_result { - let _ = msg.delete(ctx).await; - } - msg_deleted = true; - } else { - interaction_err( - ctx, - &press, - "Unable to use interaction because you are missing `MANAGE_MESSAGES`.", - ) - .await; - } - } - - if press.data.custom_id == hide_body_id { - if press.user.id == message.author.id || has_perms { - if !body_hid { - let mut hid_body_embeds: Vec = Vec::new(); - if let Ok(ref msg) = msg_result { - for mut embed in msg.embeds.clone() { - embed.description = None; - let embed: CreateEmbed = embed.clone().into(); - hid_body_embeds.push(embed); - } - } - - let _ = press - .create_response( - ctx, - serenity::CreateInteractionResponse::UpdateMessage( - serenity::CreateInteractionResponseMessage::new() - .embeds(hid_body_embeds) - .components(vec![remove.clone()]), - ), - ) - .await; - } - body_hid = true; - } else { - interaction_err( - ctx, - &press, - "Unable to use interaction because you are missing `MANAGE_MESSAGES`.", - ) - .await; - } - } - } - // Triggers on timeout. - if !msg_deleted { - if let Ok(mut msg) = msg_result { - let _ = msg - .edit(ctx, serenity::EditMessage::default().components(vec![])) - .await; - } - } - } -} - -async fn issue_embeds(data: &Data, message: &Message) -> Option> { - let mut embeds: Vec = vec![]; - let client = octocrab::instance(); - let ratelimit = client.ratelimit(); - - let regex = Regex::new(r" ?([a-zA-Z0-9-_.]+)?#([0-9]+) ?").expect("Expected numbers regex"); - - let custom_repos = { data.state.read().unwrap().issue_prefixes.clone() }; - - let mut issues = client.issues(DEFAULT_REPO_OWNER, DEFAULT_REPO_NAME); - let mut prs = client.pulls(DEFAULT_REPO_OWNER, DEFAULT_REPO_NAME); - - for capture in regex.captures_iter(&message.content) { - if let Some(m) = capture.get(2) { - let issue_num = m.as_str().parse::().expect("Match is not a number"); - - if let Some(repo) = capture.get(1) { - let repository = custom_repos.get(&repo.as_str().to_lowercase()); - if let Some(repository) = repository { - let (owner, repo) = repository.get(); - - issues = client.issues(owner, repo); - prs = client.pulls(owner, repo); - } else { - continue; // discards when it doesn't match a repo. - }; - } - - let ratelimit = ratelimit - .get() - .await - .expect("Failed to get github rate limit"); - - if ratelimit.rate.remaining > 2 { - if let Ok(pr) = prs.get(issue_num).await { - embeds.push(pr.embed()); - } else if let Ok(issue) = issues.get(issue_num).await { - embeds.push(issue.embed()); - } - } - } - } - - if embeds.is_empty() { - None - } else { - Some(embeds) - } -} - -trait Document { - fn get_title(&self) -> String; - fn get_content(&self) -> String; - fn get_colour(&self) -> Colour; - fn get_labels(&self) -> Option; -} - -impl Embeddable for Issue { - fn embed(&self) -> CreateEmbed { - let default = CreateEmbed::default(); - let author = CreateEmbedAuthor::new(&self.user.login) - .url(self.user.url.clone()) - .icon_url(self.user.avatar_url.clone()); - let mut embed = default - .title(self.get_title()) - .description(self.get_content()) - .url(self.html_url.as_str()) - .colour(self.get_colour()) - .author(author); - - if let Some(milestone) = &self.milestone { - embed = embed.field("Milestone", &milestone.title, true); - } - - if let Some(labels) = self.get_labels() { - embed = embed.field("Labels", labels, true); - } - - embed - } -} - -impl Document for Issue { - fn get_title(&self) -> String { - format!("#{}: {}", self.number, self.title) - } - - fn get_content(&self) -> String { - let body = self.body.as_deref().unwrap_or_default(); - - let mut description = String::default(); - for line in body.split('\n').take(15) { - description.push_str(&format!("{line}\n")); - } - - description.shrink_to(4096); - description - } - - fn get_colour(&self) -> Colour { - match self.closed_at { - Some(_) => CLOSED_COLOUR, - None => OPEN_COLOUR, - } - } - - fn get_labels(&self) -> Option { - if self.labels.is_empty() { - None - } else { - let labels = &self - .labels - .iter() - .map(|l| l.name.clone()) - .collect::>(); - - Some(format!("`{}`", labels.join("`, `"))) - } - } -} - -impl Embeddable for PullRequest { - fn embed(&self) -> CreateEmbed { - let mut description = self.body.clone().unwrap_or_default(); - description.shrink_to(4096); - - let default = CreateEmbed::default(); - let mut embed = default - .title(self.get_title()) - .description(self.get_content()) - .colour(self.get_colour()); - - if let Some(user) = &self.user { - let author = CreateEmbedAuthor::new(user.login.clone()) - .url(user.url.clone()) - .icon_url(user.avatar_url.clone()); - embed = embed.author(author); - } - - if let Some(url) = &self.html_url { - embed = embed.url(url.as_str()); - } - - if let Some(milestone) = &self.milestone { - embed = embed.field("Milestone", &milestone.title, true); - } - - if let Some(labels) = self.get_labels() { - embed = embed.field("Labels", labels, true); - } - - embed - } -} - -impl Document for PullRequest { - fn get_title(&self) -> String { - match &self.title { - Some(title) => format!("#{}: {}", self.number, title), - None => format!("#{}", self.number), - } - } - - fn get_content(&self) -> String { - let body = self.body.as_deref().unwrap_or_default(); - - let mut content = String::default(); - for line in body.split('\n').take(15) { - content.push_str(&format!("{line}\n")); - } - - content.shrink_to(4096); - content - } - - fn get_colour(&self) -> Colour { - match self.closed_at { - Some(_) => match self.merged_at { - Some(_) => RESOLVED_COLOUR, - None => CLOSED_COLOUR, - }, - None => OPEN_COLOUR, - } - } - - fn get_labels(&self) -> Option { - if let Some(labels) = &self.labels { - if !labels.is_empty() { - let labels = labels - .iter() - .map(|l| l.name.clone()) - .collect::>(); - - return Some(format!("`{}`", labels.join("`, `"))); - } - } - - None - } -} diff --git a/src/events/issues/mod.rs b/src/events/issues/mod.rs new file mode 100644 index 0000000..b0cc6b8 --- /dev/null +++ b/src/events/issues/mod.rs @@ -0,0 +1,190 @@ +use std::time::Duration; + +use crate::{commands::interaction_err, structures::Embeddable, Data}; + +use poise::serenity_prelude::{ + self as serenity, ButtonStyle, Context, CreateActionRow, CreateButton, CreateEmbed, + CreateInteractionResponse, Message, Permissions, +}; +use regex::Regex; +use to_arraystring::ToArrayString; + +const DEFAULT_REPO_OWNER: &str = "OpenTabletDriver"; +const DEFAULT_REPO_NAME: &str = "OpenTabletDriver"; + +mod utils; + +enum Kind { + Delete, + HideBody, +} + +impl Kind { + fn from_id(id: &str, ctx_id: &str) -> Option { + let this = match id.strip_prefix(ctx_id)? { + "delete" => Self::Delete, + "hide_body" => Self::HideBody, + _ => return None, + }; + + Some(this) + } +} + +pub async fn message(data: &Data, ctx: &Context, message: &Message) { + if let Some(embeds) = issue_embeds(data, message).await { + let typing = message.channel_id.start_typing(&ctx.http); + + // usually I would use poise context to generate a unique id, but its not available + // on events, but we also aren't handling different invocation of this on a single message, + // so its not actually needed. + + // The max length of this is known (its just a u64) and with a little neat library + // we can avoid even a stack allocation! (thanks gnome) + let ctx_id = message.id.get().to_arraystring(); + + let remove_id = format!("{ctx_id}delete"); + let hide_body_id = format!("{ctx_id}hide_body"); + + let remove = CreateActionRow::Buttons(vec![CreateButton::new(&remove_id) + .label("delete") + .style(ButtonStyle::Danger)]); + + let components = serenity::CreateActionRow::Buttons(vec![ + CreateButton::new(&remove_id) + .label("delete") + .style(ButtonStyle::Danger), + CreateButton::new(&hide_body_id).label("hide body"), + ]); + + let content: serenity::CreateMessage = serenity::CreateMessage::default() + .embeds(embeds) + .reference_message(message) + .components(vec![components]); + let msg_result = message.channel_id.send_message(ctx, content).await; + typing.stop(); + + let mut msg_deleted = false; + let mut body_hid = false; + while let Some(press) = serenity::ComponentInteractionCollector::new(ctx) + .filter(move |press| press.data.custom_id.starts_with(&*ctx_id)) + .timeout(Duration::from_secs(60)) + .await + { + let has_perms = press.member.as_ref().map_or(false, |member| { + member.permissions.map_or(false, |member_perms| { + member_perms.contains(Permissions::MANAGE_MESSAGES) + }) + }); + + // Users who do not own the message or have permissions cannot execute the interactions. + if !(press.user.id == message.author.id || has_perms) { + interaction_err( + ctx, + &press, + "Unable to use interaction because you are missing `MANAGE_MESSAGES`.", + ) + .await; + + continue; + } + + match Kind::from_id(&press.data.custom_id, &ctx_id) { + Some(Kind::Delete) => { + let _ = press + .create_response(ctx, CreateInteractionResponse::Acknowledge) + .await; + if let Ok(ref msg) = msg_result { + let _ = msg.delete(ctx).await; + } + msg_deleted = true; + } + Some(Kind::HideBody) => { + if !body_hid { + let mut hid_body_embeds: Vec = Vec::new(); + if let Ok(ref msg) = msg_result { + for mut embed in msg.embeds.clone() { + embed.description = None; + let embed: CreateEmbed = embed.clone().into(); + hid_body_embeds.push(embed); + } + } + + let _ = press + .create_response( + ctx, + serenity::CreateInteractionResponse::UpdateMessage( + serenity::CreateInteractionResponseMessage::new() + .embeds(hid_body_embeds) + .components(vec![remove.clone()]), + ), + ) + .await; + } + body_hid = true; + } + None => {} + } + } + + // Triggers on timeout. + if !msg_deleted { + if let Ok(mut msg) = msg_result { + let _ = msg + .edit(ctx, serenity::EditMessage::default().components(vec![])) + .await; + } + } + } +} + +async fn issue_embeds(data: &Data, message: &Message) -> Option> { + let mut embeds: Vec = vec![]; + let client = octocrab::instance(); + let ratelimit = client.ratelimit(); + + // TODO: stop compiling this every time. + let regex = Regex::new(r" ?([a-zA-Z0-9-_.]+)?#([0-9]+) ?").expect("Expected numbers regex"); + + let custom_repos = { data.state.read().unwrap().issue_prefixes.clone() }; + + let mut issues = client.issues(DEFAULT_REPO_OWNER, DEFAULT_REPO_NAME); + let mut prs = client.pulls(DEFAULT_REPO_OWNER, DEFAULT_REPO_NAME); + + for capture in regex.captures_iter(&message.content) { + if let Some(m) = capture.get(2) { + let issue_num = m.as_str().parse::().expect("Match is not a number"); + + if let Some(repo) = capture.get(1) { + let repository = custom_repos.get(&repo.as_str().to_lowercase()); + if let Some(repository) = repository { + let (owner, repo) = repository.get(); + + issues = client.issues(owner, repo); + prs = client.pulls(owner, repo); + } else { + continue; // discards when it doesn't match a repo. + }; + } + + let ratelimit = ratelimit + .get() + .await + .expect("Failed to get github rate limit"); + + if ratelimit.rate.remaining > 2 { + if let Ok(pr) = prs.get(issue_num).await { + embeds.push(pr.embed()); + } else if let Ok(issue) = issues.get(issue_num).await { + embeds.push(issue.embed()); + } + } + } + } + + if embeds.is_empty() { + None + } else { + Some(embeds) + } +} diff --git a/src/events/issues/utils.rs b/src/events/issues/utils.rs new file mode 100644 index 0000000..fac1047 --- /dev/null +++ b/src/events/issues/utils.rs @@ -0,0 +1,159 @@ +use octocrab::models::{issues::Issue, pulls::PullRequest}; +use poise::serenity_prelude::{Colour, CreateEmbed, CreateEmbedAuthor}; + +use crate::structures::Embeddable; + +const OPEN_COLOUR: Colour = Colour::new(0x238636); +const RESOLVED_COLOUR: Colour = Colour::new(0x8957e5); +const CLOSED_COLOUR: Colour = Colour::new(0xda3633); + +pub(super) trait Document { + fn get_title(&self) -> String; + fn get_content(&self) -> String; + fn get_colour(&self) -> Colour; + fn get_labels(&self) -> Option; +} + +impl Embeddable for Issue { + fn embed(&self) -> CreateEmbed { + let default = CreateEmbed::default(); + let author = CreateEmbedAuthor::new(&self.user.login) + .url(self.user.url.clone()) + .icon_url(self.user.avatar_url.clone()); + let mut embed = default + .title(self.get_title()) + .description(self.get_content()) + .url(self.html_url.as_str()) + .colour(self.get_colour()) + .author(author); + + if let Some(milestone) = &self.milestone { + embed = embed.field("Milestone", &milestone.title, true); + } + + if let Some(labels) = self.get_labels() { + embed = embed.field("Labels", labels, true); + } + + embed + } +} + +impl Document for Issue { + fn get_title(&self) -> String { + format!("#{}: {}", self.number, self.title) + } + + fn get_content(&self) -> String { + let body = self.body.as_deref().unwrap_or_default(); + + let mut description = String::default(); + for line in body.split('\n').take(15) { + description.push_str(&format!("{line}\n")); + } + + description.shrink_to(4096); + description + } + + fn get_colour(&self) -> Colour { + match self.closed_at { + Some(_) => CLOSED_COLOUR, + None => OPEN_COLOUR, + } + } + + fn get_labels(&self) -> Option { + if self.labels.is_empty() { + None + } else { + let labels = &self + .labels + .iter() + .map(|l| l.name.clone()) + .collect::>(); + + Some(format!("`{}`", labels.join("`, `"))) + } + } +} + +impl Embeddable for PullRequest { + fn embed(&self) -> CreateEmbed { + let mut description = self.body.clone().unwrap_or_default(); + description.shrink_to(4096); + + let default = CreateEmbed::default(); + let mut embed = default + .title(self.get_title()) + .description(self.get_content()) + .colour(self.get_colour()); + + if let Some(user) = &self.user { + let author = CreateEmbedAuthor::new(user.login.clone()) + .url(user.url.clone()) + .icon_url(user.avatar_url.clone()); + embed = embed.author(author); + } + + if let Some(url) = &self.html_url { + embed = embed.url(url.as_str()); + } + + if let Some(milestone) = &self.milestone { + embed = embed.field("Milestone", &milestone.title, true); + } + + if let Some(labels) = self.get_labels() { + embed = embed.field("Labels", labels, true); + } + + embed + } +} + +impl Document for PullRequest { + fn get_title(&self) -> String { + match &self.title { + Some(title) => format!("#{}: {}", self.number, title), + None => format!("#{}", self.number), + } + } + + fn get_content(&self) -> String { + let body = self.body.as_deref().unwrap_or_default(); + + let mut content = String::default(); + for line in body.split('\n').take(15) { + content.push_str(&format!("{line}\n")); + } + + content.shrink_to(4096); + content + } + + fn get_colour(&self) -> Colour { + match self.closed_at { + Some(_) => match self.merged_at { + Some(_) => RESOLVED_COLOUR, + None => CLOSED_COLOUR, + }, + None => OPEN_COLOUR, + } + } + + fn get_labels(&self) -> Option { + if let Some(labels) = &self.labels { + if !labels.is_empty() { + let labels = labels + .iter() + .map(|l| l.name.clone()) + .collect::>(); + + return Some(format!("`{}`", labels.join("`, `"))); + } + } + + None + } +} diff --git a/src/events/mod.rs b/src/events/mod.rs index 5b71a81..4788c69 100644 --- a/src/events/mod.rs +++ b/src/events/mod.rs @@ -3,7 +3,7 @@ use poise::serenity_prelude as serenity; use crate::{Data, Error}; pub mod code; -pub mod issue; +pub mod issues; pub async fn event_handler( ctx: &serenity::Context, @@ -15,7 +15,7 @@ pub async fn event_handler( match event { serenity::FullEvent::Message { new_message } => { if !new_message.author.bot && new_message.guild_id.is_some() { - issue::message(data, ctx, new_message).await; + issues::message(data, ctx, new_message).await; code::message(ctx, new_message).await; } }