From 6520cc65a3fdd636512ab724623c145c233cd4b5 Mon Sep 17 00:00:00 2001 From: Neshura Date: Tue, 7 May 2024 22:10:21 +0200 Subject: [PATCH] Rewrite Version 3 --- Cargo.lock | 126 ++++++++++++++++- Cargo.toml | 2 +- src/bot.rs | 307 +++++++++++------------------------------ src/config.rs | 217 +++++++++++++++++++++++++++++ src/fetchers/jnovel.rs | 236 +++++-------------------------- src/fetchers/mod.rs | 32 ++++- src/lemmy.rs | 272 +++++++++++++++++++++++++++++------- src/main.rs | 4 +- 8 files changed, 715 insertions(+), 481 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 858ab23..0f45e76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -45,12 +45,12 @@ dependencies = [ name = "aob-lemmy-bot" version = "2.2.8" dependencies = [ - "async-trait", "chrono", "confy", "lemmy_api_common", "lemmy_db_schema", "log", + "notify", "once_cell", "reqwest", "serde", @@ -189,6 +189,21 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" +[[package]] +name = "crossbeam-channel" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3db02a9c5b5121e1e42fbdb1aeb65f5e02624cc58c43f2884c6ccac0b82f95" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345" + [[package]] name = "darling" version = "0.20.3" @@ -306,6 +321,18 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" +[[package]] +name = "filetime" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ee447700ac8aa0b2f2bd7bc4462ad686ba06baa6727ac149a2d6277f0d240fd" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "windows-sys 0.52.0", +] + [[package]] name = "fnv" version = "1.0.7" @@ -336,6 +363,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures-channel" version = "0.3.29" @@ -623,6 +659,26 @@ dependencies = [ "serde", ] +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "ipnet" version = "2.9.0" @@ -644,6 +700,26 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kqueue" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7447f1ca1b7b563588a205fe93dea8df60fd981423a768bc1c0ded35ed147d0c" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "lazy_static" version = "1.4.0" @@ -784,6 +860,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f3d0b296e374a4e6f3c7b0a1f5a51d748a0d34c85e7dc48fc3fa9a87657fe09" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.48.0", ] @@ -806,6 +883,25 @@ dependencies = [ "tempfile", ] +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.4.1", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "walkdir", + "windows-sys 0.48.0", +] + [[package]] name = "num-traits" version = "0.2.17" @@ -1097,6 +1193,15 @@ version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.22" @@ -1625,6 +1730,16 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -1716,6 +1831,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "winapi-util" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d4cc384e1e73b93bafa6fb4f1df8c41695c8a91cf9c4c64358067d15a7b6c6b" +dependencies = [ + "windows-sys 0.52.0", +] + [[package]] name = "windows-core" version = "0.51.1" diff --git a/Cargo.toml b/Cargo.toml index 71b9e93..fe4b32d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,4 +31,4 @@ confy = "^0.6" toml = "^0.8" systemd-journal-logger = "^2.1.1" log = "^0.4" -async-trait = "^0.1" \ No newline at end of file +notify = "6.1.1" \ No newline at end of file diff --git a/src/bot.rs b/src/bot.rs index d756661..4539351 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -1,14 +1,10 @@ -use crate::{config::{Config, PostBody, SeriesConfig}, fetchers::{jnovel}, lemmy}; -use crate::fetchers::jnovel::JPostInfo; -use crate::lemmy::{Lemmy, PostInfo}; -use crate::post_history::SeriesHistory; -use chrono::{DateTime, Duration, Timelike, Utc}; -use lemmy_api_common::post::CreatePost; -use lemmy_db_schema::newtypes::{CommunityId, LanguageId}; -use lemmy_db_schema::PostFeatureType; -use std::collections::HashMap; +use crate::{config::{Config}}; +use crate::lemmy::{Lemmy}; +use crate::post_history::{SeriesHistory}; +use chrono::{DateTime, Duration, Utc}; +use std::sync::{Arc, RwLock}; +use notify::{Event, EventKind, event::{AccessKind, AccessMode}, RecursiveMode, Watcher}; use tokio::time::sleep; -use crate::fetchers::Fetcher; use systemd_journal_logger::connected_to_journal; macro_rules! info { @@ -20,15 +16,6 @@ macro_rules! info { }; } -macro_rules! warn { - ($msg:tt) => { - match connected_to_journal() { - true => log::warn!("[WARN] {}", $msg), - false => println!("[WARN] {}", $msg), - } - }; -} - macro_rules! error { ($msg:tt) => { match connected_to_journal() { @@ -38,231 +25,97 @@ macro_rules! error { }; } -pub(crate) async fn run() { - let mut last_reload: DateTime; - let mut lemmy: Lemmy; - let mut login_error: bool; - let mut communities: HashMap; - let mut post_history: SeriesHistory; - let mut start: DateTime; - let mut config: Config = Config::load(); - last_reload = Utc::now(); +pub(crate) struct Bot { + shared_config: Arc>, + history: SeriesHistory, + run_start_time: DateTime +} - lemmy = match lemmy::login(&config).await { - Ok(data) => data, - Err(_) => panic!(), - }; - login_error = false; +enum Wait { + Absolute, + Buffer +} - communities = match lemmy.get_communities().await { - Ok(data) => data, - Err(_) => panic!(), - }; +impl Bot { + pub(crate) fn new() -> Self { + let config = Config::load(); + let shared_config: Arc> = Arc::new(RwLock::new(config)); - start = Utc::now(); + let shared_config_copy = shared_config.clone(); + let mut watcher = notify::recommended_watcher(move |res: Result| { + match res { + Ok(event) => { + if event.kind == EventKind::Access(AccessKind::Close(AccessMode::Write)) { + let mut write = shared_config_copy.write().expect("Write Lock Failed"); + let new_config = Config::load(); + write.series = new_config.series; + write.instance = new_config.instance; + write.protected_communities = new_config.protected_communities; + write.status_post_url = new_config.status_post_url; + info!("Reloaded Configuration"); + } + }, + Err(e) => { + let msg = format!("Error watching files: {e}"); + error!(msg); + } + } + }).expect("Watcher Error"); - let info_msg = "Bot init successful, starting normal operations".to_owned(); - info!(info_msg); + watcher.watch(&Config::get_path(), RecursiveMode::NonRecursive).expect("Error in watcher"); - loop { - idle(&start, &config).await; - start = Utc::now(); + let history: SeriesHistory = SeriesHistory::load_history(); - // replace with watcher - if start - last_reload >= Duration::seconds(config.config_reload_seconds as i64) { - config = Config::load(); - let message = "Config reloaded".to_owned(); - info!(message); - } - - if login_error { - let info_msg = "Login invalid, refreshing session"; - info!(info_msg); - lemmy = match lemmy::login(&config).await { + Bot { shared_config, history, run_start_time: Utc::now() } + } + pub(crate) async fn run(&mut self) { + loop { + let mut lemmy = match Lemmy::new(&self.shared_config).await { Ok(data) => data, Err(_) => continue, }; - login_error = false; - } - if start - last_reload >= Duration::seconds(config.config_reload_seconds as i64) { - communities = match lemmy.get_communities().await { - Ok(data) => data, - Err(_) => { - login_error = true; - continue; + lemmy.get_communities().await; + + let start: DateTime = Utc::now(); + while Utc::now() - start <= Duration::minutes(60) { + self.run_start_time = Utc::now(); + self.ping_status().await; + let read_copy = self.shared_config.read().expect("Read Lock Failed").clone(); + for series in read_copy.series { + series.update(&mut self.history, &lemmy, &self.shared_config).await; + self.wait(1, Wait::Absolute).await; } - }; - let message = "Communities reloaded".to_owned(); - info!(message); - last_reload = Utc::now(); - } - - post_history = SeriesHistory::load_history(); - - let series = config.series.clone(); - for series in series { - sleep(Duration::seconds(1).to_std().unwrap()).await; - let info_msg = format!("Handling Series {}", series.slug); - info!(info_msg); - if handle_series(&series, &communities, &lemmy, &config, &mut post_history) - .await - .is_err() - { - login_error = true; - continue; - }; - } - - idle(&start, &config).await; - } -} - -async fn idle(start: &DateTime, config: &Config) { - let mut sleep_duration = Duration::seconds(30); - let info_msg = format!("Idling for {} seconds", sleep_duration.num_seconds()); - info!(info_msg); - if Utc::now() - start > sleep_duration { - sleep_duration = Duration::seconds(60); - } - - if let Some(status_url) = config.status_post_url.clone() { - match reqwest::get(status_url).await { - Ok(_) => {} - Err(e) => { - let err_msg = format!("{e}"); - error!(err_msg); + self.wait(30, Wait::Buffer).await; + self.ping_status().await; + self.wait(30, Wait::Absolute).await; } + + lemmy.logout().await; } - }; - - while Utc::now() - start < sleep_duration { - sleep(Duration::milliseconds(100).to_std().unwrap()).await; - } -} - -async fn handle_series(series: &SeriesConfig, communities: &HashMap, lemmy: &Lemmy, config: &Config, post_history: &mut SeriesHistory ) -> Result<(), ()> { - let jnc = jnovel::JFetcherOptions::new(series.slug.clone(), series.parted); - let post_list = match jnc.check_feed().await { - Ok(data) => data, - Err(_) => return Err(()), - }; - - if post_list.is_empty() && Utc::now().minute() % 10 == 0 { - let info_msg = "No Updates found"; - info!(info_msg); } - for post_info in post_list.clone().iter() { - let post_part_info = post_info.get_part_info(); - let post_lemmy_info = post_info.get_info(); - - if post_history.check_for_post( - series.slug.as_str(), - post_part_info.as_string().as_str(), - post_lemmy_info.title.as_str(), - ) { - continue; - } - - let post_series_config = match post_info { - JPostInfo::Chapter { .. } => &series.prepub_community, - JPostInfo::Volume { .. } => &series.volume_community, - }; - - let community_id = *communities - .get(post_series_config.name.as_str()) - .expect("Given community is invalid"); - - let post_body = match &post_series_config.post_body { - PostBody::None => None, - PostBody::Description => post_info.get_description(), - PostBody::Custom(text) => Some(text.clone()), - }; - - let post_data = CreatePost { - name: post_lemmy_info.title.clone(), - community_id, - url: Some(post_lemmy_info.url), - body: post_body, - honeypot: None, - nsfw: None, - language_id: Some(LanguageId(37)), // TODO get this id once every few hours per API request, the ordering of IDs suggests that the EN Id might change in the future - }; - - let info = format!( - "Posting '{}' to {}", - post_lemmy_info.title.as_str(), - post_series_config.name.as_str() - ); - info!(info); - let post_id = lemmy.post(post_data).await?; - - if post_series_config.pin_settings.pin_new_post_community - && config - .protected_communities - .contains(&post_series_config.name) - { - let info = format!( - "Pinning '{}' to {}", - post_lemmy_info.title, - post_series_config.name.as_str() - ); - info!(info); - let pinned_posts = lemmy.get_community_pinned(community_id).await?; - if !pinned_posts.is_empty() { - let community_pinned_post = &pinned_posts[0]; - lemmy - .unpin(community_pinned_post.post.id, PostFeatureType::Community) - .await?; - } - lemmy.pin(post_id, PostFeatureType::Community).await?; - } else if config - .protected_communities - .contains(&post_series_config.name) - { - let message = format!( - "Community '{}' for Series '{}' is protected. Is this intended?", - &post_series_config.name, series.slug - ); - warn!(message); - } - - if post_series_config.pin_settings.pin_new_post_local { - let info = format!("Pinning '{}' to Instance", post_lemmy_info.title); - info!(info); - let pinned_posts = lemmy.get_local_pinned().await?; - if !pinned_posts.is_empty() { - for pinned_post in pinned_posts { - if config - .protected_communities - .contains(&pinned_post.community.name) - { - continue; - } else { - let community_pinned_post = &pinned_post; - lemmy - .unpin(community_pinned_post.post.id, PostFeatureType::Local) - .await?; - break; - } + async fn ping_status(&self) { + let read_config = &self.shared_config.read().expect("Read Lock Failed").clone(); + if let Some(status_url) = &read_config.status_post_url { + match reqwest::get(status_url).await { + Ok(_) => {}, + Err(e) => { + let err_msg = format!("While pinging status URL: {e}"); + error!(err_msg); } } - lemmy.pin(post_id, PostFeatureType::Local).await?; } - - let mut series_history = post_history.get_series(series.slug.as_str()); - let mut part_history = series_history.get_part(post_part_info.as_string().as_str()); - - match post_info { - JPostInfo::Chapter { .. } => part_history.chapter = post_info.get_info().title, - JPostInfo::Volume { .. } => part_history.volume = post_info.get_info().title, - } - - series_history.set_part(post_part_info.as_string().as_str(), part_history); - post_history - .set_series(series.slug.as_str(), series_history); - post_history.save_history(); } - Ok(()) + + async fn wait(&self, seconds: i64, start_time: Wait) { + let duration: Duration = Duration::seconds(seconds); + let start_time: DateTime = match start_time { + Wait::Absolute => Utc::now(), + Wait::Buffer => self.run_start_time, + }; + while Utc::now() - start_time < duration { + sleep(Duration::milliseconds(100).to_std().unwrap()).await + } + } } diff --git a/src/config.rs b/src/config.rs index 776d868..0bee169 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,42 @@ +use std::path::PathBuf; +use std::sync::{Arc, RwLock}; +use chrono::{Timelike, Utc}; use crate::config::PostBody::Description; use lemmy_api_common::sensitive::Sensitive; +use lemmy_db_schema::PostFeatureType; use serde_derive::{Deserialize, Serialize}; +use crate::lemmy::{Lemmy, PartInfo, PostType}; +use crate::post_history::{SeriesHistory}; +use systemd_journal_logger::connected_to_journal; +use crate::fetchers::{FetcherTrait, Fetcher}; +use crate::fetchers::jnovel::{JNovelFetcher}; + +macro_rules! info { + ($msg:tt) => { + match connected_to_journal() { + true => log::info!("[INFO] {}", $msg), + false => println!("[INFO] {}", $msg), + } + }; +} + +macro_rules! warn { + ($msg:tt) => { + match connected_to_journal() { + true => log::warn!("[WARN] {}", $msg), + false => println!("[WARN] {}", $msg), + } + }; +} + +macro_rules! error { + ($msg:tt) => { + match connected_to_journal() { + true => log::error!("[ERROR] {}", $msg), + false => eprintln!("[ERROR] {}", $msg), + } + }; +} #[derive(Serialize, Deserialize, Clone, Debug)] pub(crate) struct Config { @@ -40,6 +76,10 @@ impl Config { cfg } + pub(crate) fn get_path() -> PathBuf { + confy::get_configuration_file_path(env!("CARGO_PKG_NAME"), "config").expect("Application will not without confy") + } + pub(crate) fn get_username(&self) -> Sensitive { Sensitive::new(self.username.clone()) } @@ -69,6 +109,183 @@ pub(crate) struct SeriesConfig { pub(crate) parted: bool, pub(crate) prepub_community: PostConfig, pub(crate) volume_community: PostConfig, + pub(crate) fetcher: Fetcher +} + +impl SeriesConfig { + pub(crate) async fn update(&self, history: &mut SeriesHistory, lemmy: &Lemmy, config: &Arc>) { + let info_msg = format!("Checking {} for Updates", self.slug); + info!(info_msg); + + let mut fetcher: Fetcher = match &self.fetcher { + Fetcher::Jnc(_) => { + Fetcher::Jnc(JNovelFetcher::new()) + }, + /*default => { + let err_msg = format!("Fetcher {default} not implemented"); + error!(err_msg); + return; + }*/ + }; + + match fetcher { + Fetcher::Jnc(ref mut jnc) => { + jnc.set_series(self.slug.clone()); + jnc.set_part_option(self.parted); + } + } + + let post_list = match fetcher.check_feed().await { + Ok(data) => data, + Err(_) => { + let err_msg = format!("While checking feed for {}", self.slug); + error!(err_msg); + return; + } + }; + + if post_list.is_empty() && Utc::now().minute() % 10 == 0 { + let info_msg = "No Updates found"; + info!(info_msg); + } + + for post_info in post_list.iter() { + if history.check_for_post( + self.slug.as_str(), + post_info.get_part_info().unwrap_or(PartInfo::NoParts).as_string().as_str(), + post_info.get_info().title.as_str() + ) { + continue + } + + let post_data = post_info.get_post_data(self, lemmy); + + let info = format!( + "Posting '{}' to {}", + post_info.get_info().title.as_str(), + post_info.get_post_config(self).name.as_str() + ); + info!(info); + + let post_id = match lemmy.post(post_data).await { + Ok(data) => data, + Err(_) => { + error!("Error posting chapter"); + return; + } + }; + + let read_config = config.read().expect("Read Lock Failed").clone(); + + if post_info.get_post_config(self).pin_settings.pin_new_post_community + && !read_config + .protected_communities + .contains(&post_info.get_post_config(self).name) + { + let info = format!( + "Pinning '{}' to {}", + post_info.get_info().title, + post_info.get_post_config(self).name.as_str() + ); + info!(info); + let pinned_posts = match lemmy.get_community_pinned(lemmy.get_community_id(&post_info.get_post_config(self).name)).await { + Ok(data) => data, + Err(_) => { + error!("Pinning of Post to community failed"); + continue; + } + }; + if !pinned_posts.is_empty() { + let community_pinned_post = &pinned_posts[0]; + match lemmy + .unpin(community_pinned_post.post.id, PostFeatureType::Community) + .await { + Ok(_) => {} + Err(_) => { + error!("Error un-pinning post"); + return; + } + } + } + match lemmy.pin(post_id, PostFeatureType::Community).await { + Ok(_) => {} + Err(_) => { + error!("Error pinning post"); + return; + } + } + } else if read_config + .protected_communities + .contains(&post_info.get_post_config(self).name) + { + let message = format!( + "Community '{}' for Series '{}' is protected. Is this intended?", + &post_info.get_post_config(self).name, self.slug + ); + warn!(message); + } + + if post_info.get_post_config(self).pin_settings.pin_new_post_local { + let info = format!("Pinning '{}' to Instance", post_info.get_info().title); + info!(info); + let pinned_posts = match lemmy.get_local_pinned().await { + Ok(data) => {data} + Err(_) => { + error!("Error fetching pinned posts"); + return; + } + }; + + if !pinned_posts.is_empty() { + for pinned_post in pinned_posts { + if read_config + .protected_communities + .contains(&pinned_post.community.name) + { + continue; + } else { + let community_pinned_post = &pinned_post; + match lemmy + .unpin(community_pinned_post.post.id, PostFeatureType::Local) + .await { + Ok(_) => {} + Err(_) => { + error!("Error pinning post"); + return; + } + } + break; + } + } + } + match lemmy.pin(post_id, PostFeatureType::Local).await { + Ok(_) => {} + Err(_) => { + error!("Error pinning post"); + return; + } + }; + } + + let mut series_history = history.get_series(self.slug.as_str()); + let mut part_history = series_history.get_part(post_info.get_part_info().unwrap_or(PartInfo::NoParts).as_string().as_str()); + + match post_info.post_type { + Some(post_type) => { + match post_type { + PostType::Chapter => part_history.chapter = post_info.get_info().title, + PostType::Volume => part_history.volume = post_info.get_info().title, + } + } + None => part_history.chapter = post_info.get_info().title, + } + + series_history.set_part(post_info.get_part_info().unwrap_or(PartInfo::NoParts).as_string().as_str(), part_history); + history + .set_series(self.slug.as_str(), series_history); + history.save_history(); + } + } } #[derive(Debug, Serialize, Deserialize, Clone)] diff --git a/src/fetchers/jnovel.rs b/src/fetchers/jnovel.rs index a2fdf6d..7ce0a6a 100644 --- a/src/fetchers/jnovel.rs +++ b/src/fetchers/jnovel.rs @@ -1,16 +1,13 @@ -use crate::{HTTP_CLIENT, lemmy}; +use crate::{HTTP_CLIENT}; use chrono::{DateTime, Duration, Utc}; use serde_derive::{Deserialize, Serialize}; -use std::cmp::Ordering; use std::collections::HashMap; use std::ops::Sub; -use async_trait::async_trait; use url::Url; -use crate::fetchers::Fetcher; -use crate::fetchers::jnovel::JPostInfo::{Chapter, Volume}; -use crate::fetchers::jnovel::PartInfo::{NoParts, Part}; -use crate::lemmy::{PostInfo, PostInfoInner}; +use crate::fetchers::{FetcherTrait}; +use crate::lemmy::{PartInfo, PostInfo, PostInfoInner, PostType}; use systemd_journal_logger::connected_to_journal; +use crate::lemmy::PartInfo::{NoParts, Part}; macro_rules! error { ($msg:tt) => { @@ -91,191 +88,31 @@ pub(crate) struct ChapterDetail { pub(crate) cover: Option, } -#[derive(Debug, Copy, Clone)] -pub(crate) enum PartInfo { - NoParts, - Part(u8), -} - -impl PartInfo { - pub(crate) fn as_u8(&self) -> u8 { - match self { - Part(number) => *number, - NoParts => 0, - } - } - - pub(crate) fn as_string(&self) -> String { - self.as_u8().to_string() - } -} - -impl PartialEq for PartInfo { - fn eq(&self, other: &Self) -> bool { - let self_numeric = self.as_u8(); - let other_numeric = other.as_u8(); - self_numeric == other_numeric - } -} - -impl PartialOrd for PartInfo { - fn partial_cmp(&self, other: &Self) -> Option { - if self.gt(other) { - Some(Ordering::Greater) - } else if self.eq(other) { - Some(Ordering::Equal) - } else { - Some(Ordering::Less) - } - } - - fn lt(&self, other: &Self) -> bool { - let self_numeric = self.as_u8(); - let other_numeric = other.as_u8(); - - self_numeric < other_numeric - } - - fn le(&self, other: &Self) -> bool { - !self.gt(other) - } - - fn gt(&self, other: &Self) -> bool { - let self_numeric = self.as_u8(); - let other_numeric = other.as_u8(); - - self_numeric > other_numeric - } - - fn ge(&self, other: &Self) -> bool { - !self.lt(other) - } -} - -#[derive(Debug, Clone)] -pub(crate) enum JPostInfo { - Chapter { - part: PartInfo, - lemmy_info: PostInfoInner, - }, - Volume { - part: PartInfo, - description: String, - lemmy_info: PostInfoInner, - }, -} - -impl JPostInfo { - pub(crate) fn get_part_info(&self) -> PartInfo { - match self { - Chapter { - part: part_info, .. - } => *part_info, - Volume { - part: part_info, .. - } => *part_info, - } - } -} - -impl PostInfo for JPostInfo { - fn get_info(&self) -> PostInfoInner { - match self { - Chapter { lemmy_info, .. } => lemmy_info.clone(), - Volume { lemmy_info, .. } => lemmy_info.clone(), - } - } - - fn get_description(&self) -> Option { - match self { - Chapter { .. } => None, - Volume { description, .. } => Some(description.clone()), - } - } -} - -impl PartialEq for JPostInfo { - fn eq(&self, other: &Self) -> bool { - let self_part = match self { - Chapter { part, .. } => part, - Volume { part, .. } => part, - }; - - let other_part = match other { - Chapter { part, .. } => part, - Volume { part, .. } => part, - }; - - self_part.eq(other_part) - } -} - -impl PartialOrd for JPostInfo { - fn partial_cmp(&self, other: &Self) -> Option { - if self.gt(other) { - Some(Ordering::Greater) - } else if self.eq(other) { - Some(Ordering::Equal) - } else { - Some(Ordering::Less) - } - } - - fn lt(&self, other: &Self) -> bool { - let self_part = match self { - Chapter { part, .. } => part, - Volume { part, .. } => part, - }; - - let other_part = match other { - Chapter { part, .. } => part, - Volume { part, .. } => part, - }; - - self_part < other_part - } - - fn le(&self, other: &Self) -> bool { - !self.gt(other) - } - - fn gt(&self, other: &Self) -> bool { - let self_part = match self { - Chapter { part, .. } => part, - Volume { part, .. } => part, - }; - - let other_part = match other { - Chapter { part, .. } => part, - Volume { part, .. } => part, - }; - - self_part > other_part - } - - fn ge(&self, other: &Self) -> bool { - !self.lt(other) - } -} - -pub(crate) struct JFetcherOptions { +#[derive(Deserialize, Serialize, Debug, Clone)] +pub(crate) struct JNovelFetcher { series_slug: String, series_has_parts: bool } -impl JFetcherOptions { - pub(crate) fn new(series_slug: String, series_has_parts: bool) -> Self { - JFetcherOptions { - series_slug, - series_has_parts - } +impl JNovelFetcher { + pub(crate) fn set_series(&mut self, series: String) { + self.series_slug = series; + } + + pub(crate) fn set_part_option(&mut self, has_parts: bool) { + self.series_has_parts = has_parts; } } -#[async_trait] -impl Fetcher for JFetcherOptions { - type Return = JPostInfo; - async fn check_feed(&self) -> Result, ()> { +impl FetcherTrait for JNovelFetcher { + fn new() -> Self { + JNovelFetcher { + series_slug: "".to_owned(), + series_has_parts: false + } + } + + async fn check_feed(&self) -> Result, ()> { let response = match HTTP_CLIENT .get(api_url!() + "/series/" + self.series_slug.as_str() + "/volumes?format=json") .send() @@ -307,8 +144,8 @@ impl Fetcher for JFetcherOptions { volume_brief_data.volumes.reverse(); // Makes breaking out of the volume loop easier // If no parts just use 0 as Part indicator as no Series with Parts has a Part 0 - let mut volume_map: HashMap = HashMap::new(); - let mut prepub_map: HashMap = HashMap::new(); + let mut volume_map: HashMap = HashMap::new(); + let mut prepub_map: HashMap = HashMap::new(); for volume in volume_brief_data.volumes.iter() { let publishing_date = DateTime::parse_from_rfc3339(&volume.publishing).unwrap(); @@ -351,14 +188,15 @@ impl Fetcher for JFetcherOptions { self.series_slug.as_str(), volume.number ); - let post_details = lemmy::PostInfoInner { + let post_details = PostInfoInner { title: volume.title.clone(), url: Url::parse(&post_url).unwrap(), }; - let new_post_info = Volume { - part: new_part_info, - description: volume.short_description.clone(), + let new_post_info = PostInfo { + post_type: Some(PostType::Volume), + part: Some(new_part_info), + description: Some(volume.short_description.clone()), lemmy_info: post_details, }; @@ -376,9 +214,11 @@ impl Fetcher for JFetcherOptions { } if let Some(prepub_info) = get_latest_prepub(&volume.slug).await? { - let prepub_post_info = Chapter { - part: new_part_info, + let prepub_post_info = PostInfo { + post_type: Some(PostType::Chapter), + part: Some(new_part_info), lemmy_info: prepub_info, + description: None, }; prepub_map @@ -392,8 +232,8 @@ impl Fetcher for JFetcherOptions { } } - let mut result_vec: Vec = volume_map.values().cloned().collect(); - let mut prepub_vec: Vec = prepub_map.values().cloned().collect(); + let mut result_vec: Vec = volume_map.values().cloned().collect(); + let mut prepub_vec: Vec = prepub_map.values().cloned().collect(); result_vec.append(&mut prepub_vec); Ok(result_vec) @@ -401,7 +241,7 @@ impl Fetcher for JFetcherOptions { } -async fn get_latest_prepub(volume_slug: &str) -> Result, ()> { +async fn get_latest_prepub(volume_slug: &str) -> Result, ()> { let response = match HTTP_CLIENT .get(api_url!() + "/volumes/" + volume_slug + "/parts?format=json") .send() @@ -432,7 +272,7 @@ async fn get_latest_prepub(volume_slug: &str) -> Result = None; + let mut post_details: Option = None; for prepub_part in volume_prepub_parts_data.parts.iter() { let publishing_date = DateTime::parse_from_rfc3339(&prepub_part.launch).unwrap(); @@ -443,7 +283,7 @@ async fn get_latest_prepub(volume_slug: &str) -> Result Result, ()>; +pub(crate) trait FetcherTrait { + fn new() -> Self where Self: Sized; + async fn check_feed(&self) -> Result, ()>; +} + +impl Fetcher { + pub(crate) async fn check_feed(&self) -> Result, ()> { + match self { + Jnc(fetcher) => fetcher.check_feed().await, + /*default => { + let err_msg = format!("Fetcher {default} is not implemented"); + error!(err_msg); + Err(()) + }*/ + } + } +} + +#[derive(Deserialize, Serialize, Debug, Clone, Display)] +pub(crate) enum Fetcher { + #[serde(rename = "jnc")] + Jnc(JNovelFetcher) } diff --git a/src/lemmy.rs b/src/lemmy.rs index 32f45ce..29b2408 100644 --- a/src/lemmy.rs +++ b/src/lemmy.rs @@ -1,14 +1,16 @@ -use crate::config::Config; +use std::cmp::Ordering; +use crate::config::{Config, PostBody, PostConfig, SeriesConfig}; use crate::{HTTP_CLIENT}; use lemmy_api_common::community::{ListCommunities, ListCommunitiesResponse}; use lemmy_api_common::lemmy_db_views::structs::PostView; use lemmy_api_common::person::{Login, LoginResponse}; use lemmy_api_common::post::{CreatePost, FeaturePost, GetPosts, GetPostsResponse}; use lemmy_api_common::sensitive::Sensitive; -use lemmy_db_schema::newtypes::{CommunityId, PostId}; +use lemmy_db_schema::newtypes::{CommunityId, LanguageId, PostId}; use lemmy_db_schema::{ListingType, PostFeatureType}; use reqwest::StatusCode; use std::collections::HashMap; +use std::sync::{RwLock}; use serde::{Deserialize, Serialize}; use url::Url; use systemd_journal_logger::connected_to_journal; @@ -25,6 +27,7 @@ macro_rules! error { pub(crate) struct Lemmy { jwt_token: Sensitive, instance: String, + communities: HashMap, } @@ -34,60 +37,221 @@ pub(crate) struct PostInfoInner { pub(crate) url: Url, } -pub(crate) trait PostInfo { - fn get_info(&self) -> PostInfoInner; - - fn get_description(&self) -> Option; +#[derive(Debug, Copy, Clone)] +pub(crate) enum PartInfo { + NoParts, + Part(u8), } -pub(crate) async fn login(config: &Config) -> Result { - let login_params = Login { - username_or_email: config.get_username(), - password: config.get_password(), - totp_2fa_token: None, - }; - - let response = match HTTP_CLIENT - .post(config.instance.to_owned() + "/api/v3/user/login") - .json(&login_params) - .send() - .await - { - Ok(data) => data, - Err(e) => { - let err_msg = format!("{e}"); - error!(err_msg); - return Err(()); +impl PartInfo { + pub(crate) fn as_u8(&self) -> u8 { + match self { + PartInfo::Part(number) => *number, + PartInfo::NoParts => 0, } - }; + } - match response.status() { - StatusCode::OK => { - let data: LoginResponse = response - .json() - .await - .expect("Successful Login Request should return JSON"); - match data.jwt { - Some(token) => Ok(Lemmy { - jwt_token: token.clone(), - instance: config.instance.to_owned(), - }), - None => { - let err_msg = "Login did not return JWT token. Are the credentials valid?".to_owned(); - error!(err_msg); - Err(()) + pub(crate) fn as_string(&self) -> String { + self.as_u8().to_string() + } +} + +impl PartialEq for PartInfo { + fn eq(&self, other: &Self) -> bool { + let self_numeric = self.as_u8(); + let other_numeric = other.as_u8(); + self_numeric == other_numeric + } +} + +impl PartialOrd for PartInfo { + fn partial_cmp(&self, other: &Self) -> Option { + if self.gt(other) { + Some(Ordering::Greater) + } else if self.eq(other) { + Some(Ordering::Equal) + } else { + Some(Ordering::Less) + } + } + + fn lt(&self, other: &Self) -> bool { + let self_numeric = self.as_u8(); + let other_numeric = other.as_u8(); + + self_numeric < other_numeric + } + + fn le(&self, other: &Self) -> bool { + !self.gt(other) + } + + fn gt(&self, other: &Self) -> bool { + let self_numeric = self.as_u8(); + let other_numeric = other.as_u8(); + + self_numeric > other_numeric + } + + fn ge(&self, other: &Self) -> bool { + !self.lt(other) + } +} + +#[derive(Debug, Clone, Copy)] +pub(crate) enum PostType { + Chapter, + Volume +} + +#[derive(Debug, Clone)] +pub(crate) struct PostInfo { + pub(crate) part: Option, + pub(crate) lemmy_info: PostInfoInner, + pub(crate) description: Option, + pub(crate) post_type: Option +} + +impl PostInfo { + pub(crate)fn get_info(&self) -> PostInfoInner { + self.lemmy_info.clone() + } + + pub(crate)fn get_description(&self) -> Option { + self.description.clone() + } + + pub(crate) fn get_part_info(&self) -> Option { + self.part + } + + pub(crate) fn get_post_config(&self, series: &SeriesConfig) -> PostConfig { + match self.post_type { + Some(post_type) => { + match post_type { + PostType::Chapter => series.prepub_community.clone(), + PostType::Volume => series.volume_community.clone(), } } + None => series.prepub_community.clone(), } - status => { - let err_msg = format!("Unexpected HTTP Status '{}' during Login", status); - error!(err_msg); - Err(()) + } + + pub(crate) fn get_post_data(&self, series: &SeriesConfig, lemmy: &Lemmy) -> CreatePost { + let post_config = self.get_post_config(series); + + let post_body = match &post_config.post_body { + PostBody::None => None, + PostBody::Description => self.get_description(), + PostBody::Custom(text) => Some(text.clone()), + }; + + let community_id: CommunityId = lemmy.get_community_id(&post_config.name); + + CreatePost { + name: self.get_info().title.clone(), + community_id, + url: Some(self.get_info().url), + body: post_body, + honeypot: None, + nsfw: None, + language_id: Some(LanguageId(37)), // TODO get this id once every few hours per API request, the ordering of IDs suggests that the EN Id might change in the future + } + } +} + +impl PartialEq for PostInfo { + fn eq(&self, other: &Self) -> bool { + self.part.eq(&other.part) + } +} + +impl PartialOrd for PostInfo { + fn partial_cmp(&self, other: &Self) -> Option { + if self.gt(other) { + Some(Ordering::Greater) + } else if self.eq(other) { + Some(Ordering::Equal) + } else { + Some(Ordering::Less) } } + + fn lt(&self, other: &Self) -> bool { + self.part < other.part + } + + fn le(&self, other: &Self) -> bool { + !self.gt(other) + } + + fn gt(&self, other: &Self) -> bool { + self.part > other.part + } + + fn ge(&self, other: &Self) -> bool { + !self.lt(other) + } } impl Lemmy { + pub(crate) fn get_community_id(&self, name: &str) -> CommunityId { + *self.communities.get(name).expect("Given community is invalid") + } + pub(crate) async fn new(config: &RwLock) -> Result { + let read_config = config.read().expect("Read Lock Failed").clone(); + let login_params = Login { + username_or_email: read_config.get_username(), + password: read_config.get_password(), + totp_2fa_token: None, + }; + + let response = match HTTP_CLIENT + .post(read_config.instance.to_owned() + "/api/v3/user/login") + .json(&login_params) + .send() + .await + { + Ok(data) => data, + Err(e) => { + let err_msg = format!("{e}"); + error!(err_msg); + return Err(()); + } + }; + + match response.status() { + StatusCode::OK => { + let data: LoginResponse = response + .json() + .await + .expect("Successful Login Request should return JSON"); + match data.jwt { + Some(token) => Ok(Lemmy { + jwt_token: token.clone(), + instance: read_config.instance.to_owned(), + communities: HashMap::new(), + }), + None => { + let err_msg = "Login did not return JWT token. Are the credentials valid?".to_owned(); + error!(err_msg); + Err(()) + } + } + } + status => { + let err_msg = format!("Unexpected HTTP Status '{}' during Login", status); + error!(err_msg); + Err(()) + } + } + } + + pub(crate) async fn logout(&self) { + let _ = self.post_data_json("/api/v3/user/logout", &"").await; + } + + pub(crate) async fn post(&self, post: CreatePost) -> Result { let response: String = self.post_data_json("/api/v3/post", &post).await?; let json_data: PostView = self.parse_json_map(&response).await?; @@ -155,14 +319,26 @@ impl Lemmy { .collect()) } - pub(crate) async fn get_communities(&self) -> Result, ()> { + pub(crate) async fn get_communities(&mut self) { let list_params = ListCommunities { type_: Some(ListingType::Local), ..Default::default() }; - let response: String = self.get_data_query("/api/v3/community/list", &list_params).await?; - let json_data: ListCommunitiesResponse = self.parse_json(&response).await?; + let response: String = match self.get_data_query("/api/v3/community/list", &list_params).await { + Ok(data) => data, + Err(_) => { + error!("Unable to extract data from request"); + return; + } + }; + let json_data: ListCommunitiesResponse = match self.parse_json::(&response).await { + Ok(data) => data, + Err(_) => { + error!("Unable to parse data from json"); + return; + }, + }; let mut communities: HashMap = HashMap::new(); for community_view in json_data.communities { @@ -170,7 +346,7 @@ impl Lemmy { communities.insert(community.name, community.id); } - Ok(communities) + self.communities = communities; } async fn post_data_json(&self, route: &str, json: &T ) -> Result { diff --git a/src/main.rs b/src/main.rs index e5242da..5eae9ae 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ use log::{LevelFilter}; use once_cell::sync::Lazy; use reqwest::Client; use systemd_journal_logger::{JournalLog}; +use crate::bot::Bot; mod bot; mod config; @@ -25,5 +26,6 @@ async fn main() { .install() .expect("Systemd-Logger crate error"); log::set_max_level(LevelFilter::Info); - bot::run().await; + let mut bot = Bot::new(); + bot.run().await; }