use std::{ path::{Path, PathBuf}, sync::mpsc::channel, thread, time::Duration, }; use anyhow::{bail, Context, Result}; use clap::Parser; use evtclib::{Compression, Encounter, Log}; use log::{debug, error, info, warn}; use notify::{self, DebouncedEvent, RecursiveMode, Watcher}; use regex::Regex; use serde::Deserialize; mod categories; use categories::Categorizable; mod config; use config::Config; #[cfg(feature = "im-discord")] mod discord; #[cfg(any(feature = "im-discord", feature = "im-matrix"))] mod logbag; #[cfg(feature = "im-matrix")] mod matrix; const WATCH_DELAY_SECONDS: u64 = 2; const RETRY_DELAY: Duration = Duration::from_secs(5); #[derive(Parser, Debug, Clone, PartialEq, Eq, Hash)] #[clap(version = "0.1")] struct Opts { /// The configuration file path. #[clap(short, long, default_value = "ezau.toml")] config: PathBuf, #[clap(subcommand)] subcmd: SubCommand, } #[derive(Parser, Debug, Clone, PartialEq, Eq, Hash)] enum SubCommand { Watch(Watch), Upload(Upload), } /// Use the watch mode to automatically handle new logs. /// /// This watches the given directory for new files and then uploads them. #[derive(Parser, Debug, Clone, PartialEq, Eq, Hash)] struct Watch { /// The directory to watch. dirname: PathBuf, } /// Upload a single log, as it would be done by the automatic watcher. #[derive(Parser, Debug, Clone, PartialEq, Eq, Hash)] struct Upload { /// The log to upload. path: PathBuf, } fn main() { pretty_env_logger::init(); let opts = Opts::parse(); if let Err(e) = inner_main(&opts) { error!("{}", e); e.chain() .skip(1) .for_each(|cause| error!("... because: {}", cause)); std::process::exit(1); } } fn inner_main(opts: &Opts) -> Result<()> { let config = config::load(&opts.config).context("Could not load configuration")?; sanity_check(&config, opts)?; match &opts.subcmd { SubCommand::Watch(w) => watch(w, &config)?, SubCommand::Upload(u) => { let permalink = upload_log(&u.path, &config.dps_report_upload_url)?; println!("{}", permalink); let log = load_log(&u.path)?; if let Some(d) = &config.discord { discord::post_link(&config, &d.auth_token, d.channel_id, &log, &permalink) .context("Could not post link to Discord")?; } if let Some(m) = &config.matrix { for room_id in &m.room_id { matrix::post_link(&config, m.clone().into(), room_id, &log, &permalink).context( format!("Could not post link to Matrix (room_id: {})", &room_id), )?; } } } } Ok(()) } fn watch(watch: &Watch, config: &Config) -> Result<()> { let zip_evtc_re = Regex::new(r"(\.evtc\.zip|\.zevtc)$").unwrap(); let (tx, rx) = channel(); let mut watcher = notify::watcher(tx, Duration::from_secs(WATCH_DELAY_SECONDS))?; watcher .watch(&watch.dirname, RecursiveMode::Recursive) .context("Could not watch the given directory")?; info!("Watcher set up, watching {:?}", watch.dirname); loop { let event = rx.recv()?; debug!("Event: {:?}", event); if let DebouncedEvent::Create(path) = event { let path_str = path.to_str().unwrap(); if zip_evtc_re.is_match(path_str) { handle_file(config, &path)?; } } } } fn handle_file(config: &Config, filename: &Path) -> Result<()> { if !config.upload { return Ok(()); } let log = load_log(filename)?; info!("Loaded log from category {}", log.category()); if !should_upload(config, &log) { info!("Skipping log, not uploading"); return Ok(()); } let mut try_counter = 0; let permalink = loop { let result = upload_log(filename, &config.dps_report_upload_url); if let Ok(link) = result { break link; } warn!( "Upload try {} failed, retrying {} more times. Reason: {}", try_counter + 1, config.retries - try_counter, result.as_ref().unwrap_err(), ); if try_counter == config.retries { return Err(result.unwrap_err()); } try_counter += 1; thread::sleep(RETRY_DELAY); }; info!("Uploaded log, available at {}", permalink); if let Some(d) = &config.discord { discord::post_link(config, &d.auth_token, d.channel_id, &log, &permalink) .context("Could not post link to Discord")?; info!("Posted link to Discord"); } if let Some(m) = &config.matrix { for room_id in &m.room_id { matrix::post_link(config, m.clone().into(), room_id, &log, &permalink).context(format!( "Could not post link to Matrix (room_id: {})", &room_id ))?; info!("Posted link to Matrix ({})", &room_id); } } Ok(()) } fn should_upload(config: &Config, log: &Log) -> bool { // Only upload known logs if log.encounter().is_none() && !config.upload_unknown { return false; } // Only upload Skorvald if it actually was in 100 CM (and not in in lower-tier or non-CM). if log.encounter() == Some(Encounter::Skorvald) && !log.is_cm() { return false; } // Only upload logs that are long enough if log.span() < config.minimum_duration { return false; } true } fn load_log(path: &Path) -> Result { evtclib::process_file(path, Compression::Zip).map_err(Into::into) } fn upload_log(file: &Path, url: &str) -> Result { #[derive(Debug, Deserialize)] struct ApiResponse { permalink: String, } let client = reqwest::blocking::Client::new(); let form = reqwest::blocking::multipart::Form::new().file("file", file)?; let resp: ApiResponse = client .post(url) .query(&[("json", 1)]) .multipart(form) // dps.report internal processing timeout is 15 mins .timeout(Duration::from_secs(60 * 15)) .send()? .error_for_status()? .json()?; Ok(resp.permalink) } fn sanity_check(config: &Config, opts: &Opts) -> Result<()> { if config.zip { warn!( "You have zipping enabled, but zipping is no longer part of ezau. \ Arcdps automatically zips logs now." ); } if matches!(opts.subcmd, SubCommand::Watch(_)) && !config.upload { bail!("Watching but not uploading. What am I even doing here?"); } if matches!(opts.subcmd, SubCommand::Watch(_)) && config.discord.is_none() && config.matrix.is_none() && config.upload { warn!( "You are uploading logs but not posting them anywhere. \ Consider setting `upload = false` in your settings." ); } if config.discord.is_some() && !cfg!(feature = "im-discord") { bail!( "Discord is configured but ezau was built without Discord support. \ Please enable the `im-discord` feature or adjust your configuration \ to continue." ); } if config.matrix.is_some() && !cfg!(feature = "im-matrix") { bail!( "Matrix is configured but ezau was built without Matrix support. \ Please enable the `im-matrix` feature or adjust your configuration \ to continue." ); } Ok(()) } // Dummy modules for when the features are disabled #[cfg(not(feature = "im-discord"))] mod discord { use super::{config::Config, Log, Result}; use anyhow::bail; /// Stub, enable the `im-discord` feature to use this function. pub fn post_link(_: &Config, _: &str, _: u64, _: &Log, _: &str) -> Result<()> { bail!("Discord feature is disabled in this build!") } } #[cfg(not(feature = "im-matrix"))] mod matrix { use super::{config::Config, Log, Result}; use anyhow::bail; pub struct MatrixUser; impl From for MatrixUser { fn from(_: super::config::Matrix) -> MatrixUser { MatrixUser } } /// Stub, enable the `im-matrix` feature to use this function. pub fn post_link(_: &Config, _: MatrixUser, _: &str, _: &Log, _: &str) -> Result<()> { bail!("Matrix feature is disabled in this build!") } }