Add mclo.gs upload prompt

This commit is contained in:
TheKodeToad 2024-10-19 16:29:09 +01:00
parent c4da3b5f00
commit dcc08ee4ad
No known key found for this signature in database
GPG key ID: 5E39D70B4C93C38E
9 changed files with 343 additions and 80 deletions

35
src/api/mclogs.rs Normal file
View file

@ -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<String>,
pub url: Option<String>,
pub raw: Option<String>,
pub error: Option<String>,
}
pub async fn upload_log(http: &HttpClient, content: &str) -> Result<PostLogResponse> {
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<String> {
let url = format!("{MCLOGS}{RAW}/{id}");
Ok(http.get_request(&url).await?.text().await?)
}

View file

@ -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;

View file

@ -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<Regex> = 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<Regex> = 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
}

View file

@ -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<Mutex<HashSet<MessageId>>> = 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<CreateEmbed> = 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(())
}

View file

@ -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<String> {
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<String> {
let attachment = http.get_request(content).await?.bytes().await?.to_vec();
let log = String::from_utf8(attachment)?;
Ok(log)
}
}

View file

@ -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<String> {
let url = format!("{MCLOGS}{RAW}/{content}");
let log = http.get_request(&url).await?.text().await?;
Ok(log)
Ok(raw_log(&http, content).await?)
}
}

View file

@ -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<String> {
#[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),

View file

@ -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 } => {

View file

@ -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<u32> {
.filter_map(|s| s.parse().ok())
.collect::<Vec<u32>>()
}
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)
}