From dcc08ee4adf79a4bc135a1260d94143b3d93cfc3 Mon Sep 17 00:00:00 2001 From: TheKodeToad Date: Sat, 19 Oct 2024 16:29:09 +0100 Subject: [PATCH] Add mclo.gs upload prompt --- src/api/mclogs.rs | 35 +++ src/api/mod.rs | 1 + src/handlers/event/analyze_logs/heuristics.rs | 65 +++++ src/handlers/event/analyze_logs/mod.rs | 256 +++++++++++++++--- .../analyze_logs/providers/attachment.rs | 30 -- .../event/analyze_logs/providers/mclogs.rs | 10 +- .../event/analyze_logs/providers/mod.rs | 8 +- src/handlers/event/mod.rs | 4 +- src/utils/mod.rs | 14 +- 9 files changed, 343 insertions(+), 80 deletions(-) create mode 100644 src/api/mclogs.rs create mode 100644 src/handlers/event/analyze_logs/heuristics.rs delete mode 100644 src/handlers/event/analyze_logs/providers/attachment.rs diff --git a/src/api/mclogs.rs b/src/api/mclogs.rs new file mode 100644 index 0000000..3b07c9c --- /dev/null +++ b/src/api/mclogs.rs @@ -0,0 +1,35 @@ +use std::collections::HashMap; + +use eyre::{eyre, OptionExt, Result}; +use serde::{Deserialize, Serialize}; + +use super::{HttpClient, HttpClientExt}; + +const MCLOGS: &str = "https://api.mclo.gs/1"; +const UPLOAD: &str = "/log"; +const RAW: &str = "/raw"; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct PostLogResponse { + pub success: bool, + pub id: Option, + pub url: Option, + pub raw: Option, + pub error: Option, +} + +pub async fn upload_log(http: &HttpClient, content: &str) -> Result { + let url = format!("{MCLOGS}{UPLOAD}"); + let request = http + .post(url) + .form(&HashMap::from([("content", content)])) + .build()?; + + Ok(http.execute(request).await?.json().await?) +} + +pub async fn raw_log(http: &HttpClient, id: &str) -> Result { + let url = format!("{MCLOGS}{RAW}/{id}"); + + Ok(http.get_request(&url).await?.text().await?) +} diff --git a/src/api/mod.rs b/src/api/mod.rs index cb1c7bf..4646a36 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -3,6 +3,7 @@ use reqwest::Response; pub mod dadjoke; pub mod github; +pub mod mclogs; pub mod paste_gg; pub mod pluralkit; pub mod prism_meta; diff --git a/src/handlers/event/analyze_logs/heuristics.rs b/src/handlers/event/analyze_logs/heuristics.rs new file mode 100644 index 0000000..00d3174 --- /dev/null +++ b/src/handlers/event/analyze_logs/heuristics.rs @@ -0,0 +1,65 @@ +use std::sync::OnceLock; + +use log::trace; +use regex::Regex; + +pub fn looks_like_launcher_log(log: &str) -> bool { + static QT_LOG_REGEX: OnceLock = OnceLock::new(); + + trace!("Guessing whether log is launcher log"); + + let qt_log = QT_LOG_REGEX.get_or_init(|| Regex::new(r"\d\.\d{3} [CDFIW] \|").unwrap()); + qt_log.is_match(log) +} + +pub fn looks_like_mc_log(log: &str) -> bool { + static LOG4J_REGEX: OnceLock = OnceLock::new(); + + trace!("Guessing whether log is Minecraft log"); + + if log.contains("Minecraft process ID: ") { + return true; + } + + // present in almost every Minecraft version + if log.contains("Setting user: ") || log.contains("Minecraft Version: ") { + return true; + } + + if log.contains("Exception in thread ") + || log.contains("Exception: ") + || log.contains("Error: ") + || log.contains("Throwable: ") + || log.contains("Caused by: ") + { + return true; + } + + if log.contains("org.prismlauncher.EntryPoint.main(EntryPoint.java") + || log.contains("java.lang.Thread.run(Thread.java") + { + return true; + } + + let log4j = LOG4J_REGEX + .get_or_init(|| Regex::new(r"\[\d{2}:\d{2}:\d{2}\] \[.+?/(FATAL|ERROR|WARN|INFO|DEBUG|TRACE)\] ").unwrap()); + + if log4j.is_match(&log) { + return true; + } + + if log.contains("[INFO]") + || log.contains("[CONFIG]") + || log.contains("[FINE]") + || log.contains("[FINER]") + || log.contains("[FINEST]") + || log.contains("[SEVERE]") + || log.contains("[STDERR]") + || log.contains("[WARNING]") + || log.contains("[DEBUG]") + { + return true; + } + + false +} diff --git a/src/handlers/event/analyze_logs/mod.rs b/src/handlers/event/analyze_logs/mod.rs index df89913..c22ce78 100644 --- a/src/handlers/event/analyze_logs/mod.rs +++ b/src/handlers/event/analyze_logs/mod.rs @@ -1,17 +1,34 @@ -use crate::{consts::Colors, Data}; - -use eyre::Result; -use log::{debug, trace}; -use poise::serenity_prelude::{ - Context, CreateAllowedMentions, CreateEmbed, CreateMessage, Message, +use std::{ + collections::HashSet, + sync::{Mutex, OnceLock}, }; +use crate::{ + api::{mclogs, HttpClientExt}, + consts::Colors, + utils::first_text_attachment, + Data, +}; + +use color_eyre::owo_colors::OwoColorize; +use eyre::{eyre, OptionExt, Result}; +use log::{debug, trace}; +use poise::serenity_prelude::{ + ButtonStyle, ComponentInteraction, Context, CreateAllowedMentions, CreateButton, CreateEmbed, + CreateInteractionResponse, CreateInteractionResponseMessage, CreateMessage, EditMessage, + Message, MessageId, MessageType, +}; + +mod heuristics; mod issues; mod providers; use providers::find_log; -pub async fn handle(ctx: &Context, message: &Message, data: &Data) -> Result<()> { +const BUTTON_UPLOAD_YES: &str = "log-upload-yes"; +const BUTTON_UPLOAD_NO: &str = "log-upload-no"; + +pub async fn handle_message(ctx: &Context, message: &Message, data: &Data) -> Result<()> { trace!( "Checking message {} from {} for logs", message.id, @@ -19,9 +36,7 @@ pub async fn handle(ctx: &Context, message: &Message, data: &Data) -> Result<()> ); let channel = message.channel_id; - let log = find_log(&data.http_client, message).await; - - if log.is_err() { + let Ok(log) = find_log(&data.http_client, message).await else { let embed = CreateEmbed::new() .title("Analysis failed!") .description("Couldn't download log"); @@ -33,43 +48,216 @@ pub async fn handle(ctx: &Context, message: &Message, data: &Data) -> Result<()> channel.send_message(ctx, our_message).await?; - return Ok(()); - } - - let Some(log) = log? else { - debug!("No log found in message! Skipping analysis"); return Ok(()); }; - let log = log.replace("\r\n", "\n"); + let attachment = first_text_attachment(message); + + let log = match log { + Some(log) => log, + None => match attachment { + Some(attachment) => { + data.http_client + .get_request(&attachment.url) + .await? + .text() + .await? + } + None => { + debug!("No log found in message! Skipping analysis"); + return Ok(()); + } + }, + }; let issues = issues::find(&log, data).await?; + let launcher_log = heuristics::looks_like_launcher_log(&log); + let mc_log = !launcher_log && heuristics::looks_like_mc_log(&log); - let embed = { - let mut e = CreateEmbed::new().title("Log analysis"); + debug!("Heuristics: mc_log = {mc_log}, launcher_log = {launcher_log}"); - if issues.is_empty() { - e = e - .color(Colors::Green) - .description("The automatic check didn't reveal any issues, but it's possible that some issues went undetected. Please wait for a volunteer to assist you."); - } else { - e = e.color(Colors::Red); + let show_analysis = !issues.is_empty() || mc_log; + let show_upload_prompt = attachment.is_some() && (mc_log || launcher_log); - for (title, description) in issues { - e = e.field(title, description, false); - } - } - - e - }; + if !show_analysis && !show_upload_prompt { + debug!("Found log but there is nothing to respond with"); + return Ok(()); + } let allowed_mentions = CreateAllowedMentions::new().replied_user(true); - let message = CreateMessage::new() + + let mut message = CreateMessage::new() .reference_message(message) - .allowed_mentions(allowed_mentions) - .embed(embed); + .allowed_mentions(allowed_mentions); + + if show_analysis { + message = message.add_embed({ + let mut e = CreateEmbed::new().title("Log analysis"); + + if issues.is_empty() { + e = e + .color(Colors::Green) + .description("The automatic check didn't reveal any issues, but it's possible that some issues went undetected. Please wait for a volunteer to assist you."); + } else { + e = e.color(Colors::Red); + + for (title, description) in issues { + e = e.field(title, description, false); + } + } + + e + }); + } + + if show_upload_prompt { + message = message.add_embed( + CreateEmbed::new() + .title("Upload log?") + .color(Colors::Blue) + .description("Discord attachments make it difficult for volunteers to view logs. Would you like me to upload your log to [mclo.gs](https://mclo.gs/)?"), + ); + message = message + .button( + CreateButton::new(BUTTON_UPLOAD_NO) + .style(ButtonStyle::Secondary) + .label("No"), + ) + .button( + CreateButton::new(BUTTON_UPLOAD_YES) + .style(ButtonStyle::Primary) + .label("Yes"), + ); + } channel.send_message(ctx, message).await?; Ok(()) } + +pub async fn handle_component_interaction( + ctx: &Context, + interaction: &ComponentInteraction, + data: &Data, +) -> Result<()> { + if interaction.message.kind != MessageType::InlineReply { + debug!("Ignoring component interaction on message which is not a reply"); + return Ok(()); + } + + let yes = interaction.data.custom_id == BUTTON_UPLOAD_YES; + + if !yes && interaction.data.custom_id != BUTTON_UPLOAD_NO { + debug!( + "Ignoring component interaction without ID {BUTTON_UPLOAD_YES} or {BUTTON_UPLOAD_NO}" + ); + return Ok(()); + } + + // TODO: what is this called? need it to automatically be removed when going out of scope + // static ACTIVE_MUTEX_LOCK: OnceLock>> = OnceLock::new(); + // let active_mutex = ACTIVE_MUTEX_LOCK.get_or_init(|| Mutex::new(HashSet::new())); + // + // if !active_mutex.lock().unwrap().insert(interaction.message.id) { + // debug!( + // "Already handling upload button {}; returning", + // interaction.message.id + // ); + // return Ok(()); + // } + + let mut embeds: Vec = interaction + .message + .embeds + .iter() + .map(|embed| CreateEmbed::from(embed.to_owned())) + .collect(); + + if yes { + // for some reason Discord never sends us the referenced message, only its id + let message_reference = interaction + .message + .message_reference + .as_ref() + .ok_or_eyre("Missing message reference")?; + let referenced_message = ctx + .http + .get_message( + message_reference.channel_id, + message_reference + .message_id + .ok_or_eyre("Reference missing message ID")?, + ) + .await?; + + let first_attachment = first_text_attachment(&referenced_message) + .ok_or_eyre("Log attachment disappeared (should not be possible)")?; + let body = data + .http_client + .get_request(&first_attachment.url) + .await? + .text() + .await?; + + let response = mclogs::upload_log(&data.http_client, &body).await?; + + if !response.success { + let error = response + .error + .ok_or_else(|| eyre!("mclo.gs gave us an error but with no message!"))?; + + interaction + .create_response( + &ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .ephemeral(true) + .embed( + CreateEmbed::new() + .title("Upload failed") + .color(Colors::Red) + .description(&error), + ), + ), + ) + .await?; + + // active_mutex.lock().unwrap().remove(&interaction.message.id); + return Err(eyre!("Failed to upload log: {}", &error)); + } + + let url = &response + .url + .ok_or_eyre("Missing URL in mclo.gs response!")?; + + let length = embeds.len(); + + embeds[length - 1] = CreateEmbed::new() + .title("Uploaded log") + .color(Colors::Blue) + .description(url); + } else { + embeds.pop(); + } + + interaction + .create_response(ctx, CreateInteractionResponse::Acknowledge) + .await?; + + if embeds.len() == 0 { + interaction.message.delete(ctx).await?; + } else { + ctx.http + .edit_message( + interaction.channel_id, + interaction.message.id, + &EditMessage::new().embeds(embeds).components(vec![]), + vec![], + ) + .await?; + } + + // active_mutex.lock().unwrap().remove(&interaction.message.id); + + Ok(()) +} diff --git a/src/handlers/event/analyze_logs/providers/attachment.rs b/src/handlers/event/analyze_logs/providers/attachment.rs deleted file mode 100644 index 4e09c67..0000000 --- a/src/handlers/event/analyze_logs/providers/attachment.rs +++ /dev/null @@ -1,30 +0,0 @@ -use crate::api::{HttpClient, HttpClientExt}; - -use eyre::Result; -use log::trace; -use poise::serenity_prelude::Message; - -pub struct Attachment; - -impl super::LogProvider for Attachment { - async fn find_match(&self, message: &Message) -> Option { - trace!("Checking if message {} has text attachments", message.id); - - message - .attachments - .iter() - .filter_map(|a| { - a.content_type - .as_ref() - .and_then(|ct| ct.starts_with("text/").then_some(a.url.clone())) - }) - .nth(0) - } - - async fn fetch(&self, http: &HttpClient, content: &str) -> Result { - let attachment = http.get_request(content).await?.bytes().await?.to_vec(); - let log = String::from_utf8(attachment)?; - - Ok(log) - } -} diff --git a/src/handlers/event/analyze_logs/providers/mclogs.rs b/src/handlers/event/analyze_logs/providers/mclogs.rs index e89009a..532f670 100644 --- a/src/handlers/event/analyze_logs/providers/mclogs.rs +++ b/src/handlers/event/analyze_logs/providers/mclogs.rs @@ -1,4 +1,4 @@ -use crate::api::{HttpClient, HttpClientExt}; +use crate::api::{mclogs::raw_log, HttpClient, HttpClientExt}; use std::sync::OnceLock; @@ -7,9 +7,6 @@ use log::trace; use poise::serenity_prelude::Message; use regex::Regex; -const MCLOGS: &str = "https://api.mclo.gs/1"; -const RAW: &str = "/raw"; - pub struct MCLogs; impl super::LogProvider for MCLogs { @@ -22,9 +19,6 @@ impl super::LogProvider for MCLogs { } async fn fetch(&self, http: &HttpClient, content: &str) -> Result { - let url = format!("{MCLOGS}{RAW}/{content}"); - let log = http.get_request(&url).await?.text().await?; - - Ok(log) + Ok(raw_log(&http, content).await?) } } diff --git a/src/handlers/event/analyze_logs/providers/mod.rs b/src/handlers/event/analyze_logs/providers/mod.rs index bb9aa4e..15f00f5 100644 --- a/src/handlers/event/analyze_logs/providers/mod.rs +++ b/src/handlers/event/analyze_logs/providers/mod.rs @@ -8,13 +8,11 @@ use poise::serenity_prelude::Message; use regex::Regex; use self::{ - _0x0::_0x0 as _0x0st, attachment::Attachment, haste::Haste, mclogs::MCLogs, paste_gg::PasteGG, - pastebin::PasteBin, + _0x0::_0x0 as _0x0st, haste::Haste, mclogs::MCLogs, paste_gg::PasteGG, pastebin::PasteBin, }; #[path = "0x0.rs"] mod _0x0; -mod attachment; mod haste; mod mclogs; mod paste_gg; @@ -35,7 +33,6 @@ fn get_first_capture(regex: &Regex, string: &str) -> Option { #[enum_dispatch(LogProvider)] enum Provider { _0x0st, - Attachment, Haste, MCLogs, PasteGG, @@ -44,9 +41,8 @@ enum Provider { impl Provider { pub fn iterator() -> Iter<'static, Provider> { - static PROVIDERS: [Provider; 6] = [ + static PROVIDERS: [Provider; 5] = [ Provider::_0x0st(_0x0st), - Provider::Attachment(Attachment), Provider::Haste(Haste), Provider::MCLogs(MCLogs), Provider::PasteBin(PasteBin), diff --git a/src/handlers/event/mod.rs b/src/handlers/event/mod.rs index 528317d..b87fa4f 100644 --- a/src/handlers/event/mod.rs +++ b/src/handlers/event/mod.rs @@ -1,5 +1,6 @@ use crate::{api, Data, Error}; +use analyze_logs::handle_component_interaction; use log::{debug, info, trace}; use poise::serenity_prelude::{ActivityData, Context, FullEvent, OnlineStatus}; use poise::FrameworkContext; @@ -33,6 +34,7 @@ pub async fn handle( FullEvent::InteractionCreate { interaction } => { if let Some(component_interaction) = interaction.as_message_component() { give_role::handle(ctx, component_interaction).await?; + handle_component_interaction(ctx, component_interaction, data).await?; } } @@ -63,7 +65,7 @@ pub async fn handle( eta::handle(ctx, new_message).await?; expand_link::handle(ctx, &data.http_client, new_message).await?; - analyze_logs::handle(ctx, new_message, data).await?; + analyze_logs::handle_message(ctx, new_message, data).await?; } FullEvent::ReactionAdd { add_reaction } => { diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 56385cb..ff8d16a 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,4 +1,4 @@ -use poise::serenity_prelude::{CreateEmbedAuthor, User}; +use poise::serenity_prelude::{Attachment, CreateEmbedAuthor, Message, User}; pub mod messages; @@ -15,3 +15,15 @@ pub fn semver_split(version: &str) -> Vec { .filter_map(|s| s.parse().ok()) .collect::>() } + +pub fn first_text_attachment(message: &Message) -> Option<&Attachment> { + message + .attachments + .iter() + .filter(|a| { + a.content_type + .as_ref() + .is_some_and(|content_type| content_type.starts_with("text/")) + }) + .nth(0) +}