Compare commits

..

3 commits
main ... bot-v4

Author SHA1 Message Date
ebbfdcea63
Split Memory Log into Log Levels, support different lenght limits (hardcoded for now)
All checks were successful
Run Tests on Code / run-tests (push) Successful in 17s
2024-07-21 14:10:52 +02:00
ff0360ac07
Implement Settings Updating inside Bot thread, Improve Status Ping behaviour 2024-07-21 14:10:21 +02:00
5976bd59a7
(Re-)introduce Threading, Add support for different Config (Settings) Sources, Remove Async leftovers
All checks were successful
Run Tests on Code / run-tests (push) Successful in 40s
2024-07-21 02:27:16 +02:00
16 changed files with 708 additions and 331 deletions

View file

@ -137,7 +137,7 @@ jobs:
run: rm release_blobs/build.env
-
name: Release New Version
uses: actions/forgejo-release@v2
uses: actions/forgejo-release@v1
with:
direction: upload
url: https://forgejo.neshweb.net

39
Cargo.lock generated
View file

@ -54,7 +54,7 @@ dependencies = [
name = "aob-lemmy-bot"
version = "3.1.0"
dependencies = [
"async-trait",
"atomic_enum",
"chrono",
"confy",
"lemmy_api_common",
@ -62,13 +62,13 @@ dependencies = [
"log",
"notify",
"once_cell",
"parking_lot",
"reqwest",
"serde",
"serde_derive",
"serde_json",
"strum_macros",
"systemd-journal-logger",
"tokio",
"toml",
"url",
]
@ -95,6 +95,17 @@ dependencies = [
"syn",
]
[[package]]
name = "atomic_enum"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "99e1aca718ea7b89985790c94aad72d77533063fe00bc497bb79a7c2dae6a661"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "autocfg"
version = "1.1.0"
@ -445,9 +456,9 @@ dependencies = [
[[package]]
name = "futures-channel"
version = "0.3.29"
version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff4dd66668b557604244583e3e1e1eada8c5c2e96a6d0d6653ede395b78bbacb"
checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78"
dependencies = [
"futures-core",
"futures-sink",
@ -1199,9 +1210,9 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "proc-macro2"
version = "1.0.76"
version = "1.0.86"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95fc56cda0b5c3325f5fbbd7ff9fda9e02bb00bb3dac51252d2f1bfa1cb8cc8c"
checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77"
dependencies = [
"unicode-ident",
]
@ -1597,9 +1608,9 @@ dependencies = [
[[package]]
name = "syn"
version = "2.0.48"
version = "2.0.71"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f"
checksum = "b146dcf730474b4bcd16c311627b31ede9ab149045db4d6088b3becaea046462"
dependencies = [
"proc-macro2",
"quote",
@ -1747,21 +1758,9 @@ dependencies = [
"num_cpus",
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys 0.48.0",
]
[[package]]
name = "tokio-macros"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"

View file

@ -25,11 +25,11 @@ serde = "^1.0"
serde_derive = "^1.0"
serde_json = "^1.0"
strum_macros = "^0.26"
tokio = { version = "^1.37", features = ["rt", "rt-multi-thread", "macros"] }
url = "^2.5"
confy = "^0.6"
toml = "^0.8"
systemd-journal-logger = "^2.1.1"
log = "^0.4"
async-trait = "^0.1"
notify = "6.1.1"
notify = "6.1.1"
parking_lot = "0.12.3"
atomic_enum = "0.3.0"

View file

@ -6,7 +6,6 @@ instance = "https://lemmy.example.org"
username = "BotUserName"
password = "BotPassword"
status_post_url = "PostUrlForStatusMonitoring"
config_reload_seconds = 10800
protected_communities = [ "community_where_pins_should_stay" ]

View file

@ -1,136 +1,134 @@
use crate::{config::{Config}, HTTP_CLIENT};
use std::collections::HashMap;
use crate::{AtomicHealthSignal, HealthSignal, HTTP_CLIENT};
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 systemd_journal_logger::connected_to_journal;
macro_rules! debug {
($msg:tt) => {
match connected_to_journal() {
true => log::debug!("[DEBUG] {}", $msg),
false => println!("[DEBUG] {}", $msg),
}
};
}
macro_rules! info {
($msg:tt) => {
match connected_to_journal() {
true => log::info!("[INFO] {}", $msg),
false => println!("[INFO] {}", $msg),
}
};
}
macro_rules! error {
($msg:tt) => {
match connected_to_journal() {
true => log::error!("[ERROR] {}", $msg),
false => eprintln!("[ERROR] {}", $msg),
}
};
}
use std::sync::{Arc};
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread::sleep;
use parking_lot::RwLock;
use crate::logging::Logging;
use crate::settings::{BotSettings, SeriesSettings};
pub(crate) struct Bot {
shared_config: Arc<RwLock<Config>>,
settings: BotSettings,
settings_id: u16,
health_signal: Arc<AtomicHealthSignal>,
history: SeriesHistory,
run_start_time: DateTime<Utc>
login_time: Option<DateTime<Utc>>,
}
enum Wait {
Absolute,
Buffer
Buffer(DateTime<Utc>)
}
impl Bot {
pub(crate) fn new() -> Self {
let config = Config::load();
let shared_config: Arc<RwLock<Config>> = Arc::new(RwLock::new(config));
let shared_config_copy = shared_config.clone();
let mut watcher = notify::recommended_watcher(move |res: Result<Event, notify::Error>| {
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");
watcher.watch(&Config::get_path(), RecursiveMode::NonRecursive).expect("Error in watcher");
pub(crate) fn new(health_signal: Arc<AtomicHealthSignal>, settings_id: u16, settings: BotSettings) -> Self {
let history: SeriesHistory = SeriesHistory::load_history();
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,
};
lemmy.get_communities().await;
Bot { health_signal, settings_id, settings, history, login_time: None }
}
pub(crate) fn run(
&mut self,
series_settings: Arc<RwLock<HashMap<u16, SeriesSettings>>>,
bot_settings: Arc<RwLock<HashMap<u16, BotSettings>>>,
update_pending: Arc<AtomicBool>
) {
let mut last_ping = Utc::now();
if let Some(interval) = self.settings.status_post_interval() {
last_ping -= interval
}
loop {
match self.health_signal.load(Ordering::SeqCst) {
HealthSignal::RequestAlive => {
self.health_signal.store(HealthSignal::ConfirmAlive, Ordering::SeqCst);
}
HealthSignal::RequestStop => {
break;
}
_ => {}
}
if update_pending.load(Ordering::SeqCst) {
Logging::debug("Awaiting Config Update");
sleep(Duration::milliseconds(100).to_std().unwrap());
continue
}
self.settings = bot_settings.read().get(&self.settings_id).unwrap().clone();
// perform status ping
if let Some(interval) = self.settings.status_post_interval() {
if Utc::now() + Duration::seconds(1) >= last_ping + interval {
if self.ping_status() {
last_ping = Utc::now();
}
else {
continue
}
}
}
if let Some(login_time) = self.login_time {
if Utc::now() - login_time >= Duration::minutes(60) {
//self.logout();
continue
}
}
else {
//self.login();
}
self.history = SeriesHistory::load_history();
let start: DateTime<Utc> = 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;
debug!("Done Updating Series");
self.wait(1, Wait::Absolute).await;
}
debug!("Awaiting Timeout");
self.wait(30, Wait::Buffer).await;
debug!("Pinging Server");
self.ping_status().await;
debug!("Awaiting Timeout 2");
self.wait(30, Wait::Absolute).await;
series_settings.read().iter().for_each(|(id, series)| {
//let new_entries = series.check();
});
for series in series_settings.read().iter() {
//series.update(&mut self.history, &lemmy, &self.settings).await;
Logging::debug("Done Updating Series");
self.wait(1, Wait::Absolute);
}
lemmy.logout().await;
}
self.health_signal.store(HealthSignal::ConfirmStop, Ordering::SeqCst);
}
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 HTTP_CLIENT.get(status_url).send().await {
Ok(_) => {},
fn ping_status(&self) -> bool {
if let Some(status_url) = &self.settings.status_post_url() {
return match HTTP_CLIENT.get(status_url).send() {
Ok(_) => {
true
},
Err(e) => {
let err_msg = format!("While pinging status URL: {e}");
error!(err_msg);
Logging::error(err_msg.as_str());
false
}
}
}
false
}
async fn wait(&self, seconds: i64, start_time: Wait) {
fn wait(&self, seconds: i64, start_time: Wait) {
let duration: Duration = Duration::seconds(seconds);
let start_time: DateTime<Utc> = match start_time {
Wait::Absolute => Utc::now(),
Wait::Buffer => self.run_start_time,
Wait::Buffer(time) => time,
};
while Utc::now() - start_time < duration {
sleep(Duration::milliseconds(100).to_std().unwrap()).await
sleep(Duration::milliseconds(100).to_std().unwrap());
}
}
fn login(&mut self) {
todo!();
self.login_time = Some(Utc::now());
}
fn logout(&mut self) {
self.login_time = None;
todo!();
}
}

View file

@ -1,88 +1,47 @@
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use chrono::{Timelike, Utc};
use crate::config::PostBody::Description;
use crate::settings::PostBody::Description;
use lemmy_db_schema::PostFeatureType;
use lemmy_db_schema::sensitive::SensitiveString;
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! debug {
($msg:tt) => {
match connected_to_journal() {
true => log::debug!("[DEBUG] {}", $msg),
false => println!("[DEBUG] {}", $msg),
}
};
}
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),
}
};
}
use crate::logging::Logging;
use crate::settings::PostBody;
#[derive(Serialize, Deserialize, Clone, Debug)]
pub(crate) struct Config {
pub(crate) struct SingleBotConfig {
pub(crate) instance: String,
username: SensitiveString,
password: SensitiveString,
pub(crate) status_post_url: Option<String>,
pub(crate) config_reload_seconds: u32,
pub(crate) protected_communities: Vec<String>,
pub(crate) series: Vec<SeriesConfig>,
}
impl Config {
pub(crate) fn load() -> Self {
let cfg: Self = match confy::load(env!("CARGO_PKG_NAME"), "config") {
Ok(data) => data,
Err(e) => panic!("config.toml not found: {e}"),
};
if cfg.instance.is_empty() {
impl SingleBotConfig {
pub(crate) fn load(&self) -> Self {
if self.instance.is_empty() {
panic!("bot instance not set!")
}
if cfg.username.is_empty() {
if self.username.is_empty() {
panic!("bot username not set!")
}
if cfg.password.is_empty() {
if self.password.is_empty() {
panic!("bot password not provided!")
}
cfg.series.iter().for_each(|series| {
self.series.iter().for_each(|series| {
if series.prepub_community.post_body == Description {
panic!("'Description' type Post Body only supported for Volumes!")
}
});
cfg
self.clone()
}
pub(crate) fn get_path() -> PathBuf {
@ -98,14 +57,13 @@ impl Config {
}
}
impl Default for Config {
impl Default for SingleBotConfig {
fn default() -> Self {
Config {
SingleBotConfig {
instance: "".to_owned(),
username: SensitiveString::from("".to_owned()),
password: SensitiveString::from("".to_owned()),
status_post_url: None,
config_reload_seconds: 21600,
protected_communities: vec![],
series: vec![],
}
@ -122,9 +80,9 @@ pub(crate) struct SeriesConfig {
}
impl SeriesConfig {
pub(crate) async fn update(&self, history: &mut SeriesHistory, lemmy: &Lemmy, config: &Arc<RwLock<Config>>) {
pub(crate) fn update(&self, history: &mut SeriesHistory, lemmy: &Lemmy, config: &Arc<RwLock<SingleBotConfig>>) {
let info_msg = format!("Checking {} for Updates", self.slug);
info!(info_msg);
Logging::info(info_msg.as_str());
let mut fetcher: Fetcher = match &self.fetcher {
Fetcher::Jnc(_) => {
@ -144,18 +102,18 @@ impl SeriesConfig {
}
}
let post_list = match fetcher.check_feed().await {
let post_list = match fetcher.check_feed() {
Ok(data) => data,
Err(_) => {
let err_msg = format!("While checking feed for {}", self.slug);
error!(err_msg);
Logging::error(err_msg.as_str());
return;
}
};
if post_list.is_empty() && Utc::now().minute() % 10 == 0 {
let info_msg = "No Updates found";
info!(info_msg);
Logging::info(info_msg);
}
for post_info in post_list.iter() {
@ -174,12 +132,12 @@ impl SeriesConfig {
post_info.get_info().title.as_str(),
post_info.get_post_config(self).name.as_str()
);
info!(info);
Logging::info(info.as_str());
let post_id = match lemmy.post(post_data).await {
let post_id = match lemmy.post(post_data) {
Some(data) => data,
None=> {
error!("Error posting chapter");
Logging::error("Error posting chapter");
return;
}
};
@ -196,19 +154,19 @@ impl SeriesConfig {
post_info.get_info().title,
post_info.get_post_config(self).name.as_str()
);
info!(info);
let pinned_posts = lemmy.get_community_pinned(lemmy.get_community_id(&post_info.get_post_config(self).name)).await.unwrap_or_else(|| {
error!("Pinning of Post to community failed");
Logging::info(info.as_str());
let pinned_posts = lemmy.get_community_pinned(lemmy.get_community_id(&post_info.get_post_config(self).name)).unwrap_or_else(|| {
Logging::error("Pinning of Post to community failed");
vec![]
});
if !pinned_posts.is_empty() {
let community_pinned_post = &pinned_posts[0];
if lemmy.unpin(community_pinned_post.post.id, PostFeatureType::Community).await.is_none() {
error!("Error un-pinning post");
if lemmy.unpin(community_pinned_post.post.id, PostFeatureType::Community).is_none() {
Logging::error("Error un-pinning post");
}
}
if lemmy.pin(post_id, PostFeatureType::Community).await.is_none() {
error!("Error pinning post");
if lemmy.pin(post_id, PostFeatureType::Community).is_none() {
Logging::error("Error pinning post");
}
} else if read_config
.protected_communities
@ -218,16 +176,16 @@ impl SeriesConfig {
"Community '{}' for Series '{}' is protected. Is this intended?",
&post_info.get_post_config(self).name, self.slug
);
warn!(message);
Logging::warn(message.as_str());
}
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 {
Logging::info(info.as_str());
let pinned_posts = match lemmy.get_local_pinned() {
Some(data) => {data}
None => {
error!("Error fetching pinned posts");
Logging::error("Error fetching pinned posts");
vec![]
}
};
@ -241,16 +199,16 @@ impl SeriesConfig {
continue;
} else {
let community_pinned_post = &pinned_post;
if lemmy.unpin(community_pinned_post.post.id, PostFeatureType::Local).await.is_none() {
error!("Error pinning post");
if lemmy.unpin(community_pinned_post.post.id, PostFeatureType::Local).is_none() {
Logging::error("Error pinning post");
continue;
}
break;
}
}
}
if lemmy.pin(post_id, PostFeatureType::Local).await.is_none() {
error!("Error pinning post");
if lemmy.pin(post_id, PostFeatureType::Local).is_none() {
Logging::error("Error pinning post");
};
}
@ -270,7 +228,7 @@ impl SeriesConfig {
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);
debug!("Saving History");
Logging::debug("Saving History");
history.save_history();
}
}
@ -288,11 +246,3 @@ pub(crate) struct PinConfig {
pub(crate) pin_new_post_local: bool,
pub(crate) pin_new_post_community: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(tag = "body_type", content = "body_content")]
pub(crate) enum PostBody {
None,
Description,
Custom(String),
}

View file

@ -3,29 +3,10 @@ use chrono::{DateTime, Duration, Utc};
use serde_derive::{Deserialize, Serialize};
use std::collections::HashMap;
use std::ops::Sub;
use async_trait::async_trait;
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) => {
match connected_to_journal() {
true => log::error!("[ERROR] {}", $msg),
false => eprintln!("[ERROR] {}", $msg),
}
};
}
macro_rules! info {
($msg:tt) => {
match connected_to_journal() {
true => log::info!("[INFO] {}", $msg),
false => println!("[INFO] {}", $msg),
}
};
}
use crate::logging::Logging;
static PAST_DAYS_ELIGIBLE: u8 = 4;
@ -113,7 +94,6 @@ impl JNovelFetcher {
}
}
#[async_trait]
impl FetcherTrait for JNovelFetcher {
fn new() -> Self {
JNovelFetcher {
@ -122,23 +102,22 @@ impl FetcherTrait for JNovelFetcher {
}
}
async fn check_feed(&self) -> Result<Vec<PostInfo>, ()> {
fn check_feed(&self) -> Result<Vec<PostInfo>, ()> {
let response = match HTTP_CLIENT
.get(api_url!() + "/series/" + self.series_slug.as_str() + "/volumes?format=json")
.send()
.await
{
Ok(data) => match data.text().await {
Ok(data) => match data.text() {
Ok(data) => data,
Err(e) => {
let err_msg = format!("While checking feed: {e}");
error!(err_msg);
Logging::error(err_msg.as_str());
return Err(());
}
},
Err(e) => {
let err_msg = format!("{e}");
error!(err_msg);
Logging::error(err_msg.as_str());
return Err(());
}
};
@ -147,7 +126,7 @@ impl FetcherTrait for JNovelFetcher {
Ok(data) => data,
Err(e) => {
let err_msg = format!("{e}");
error!(err_msg);
Logging::error(err_msg.as_str());
return Err(());
}
};
@ -184,7 +163,7 @@ impl FetcherTrait for JNovelFetcher {
match part_number {
Some(number) => new_part_info = Part(number),
None => {
info!("No Part found, assuming 1");
Logging::info("No Part found, assuming 1");
new_part_info = Part(1);
}
}
@ -224,7 +203,7 @@ impl FetcherTrait for JNovelFetcher {
.or_insert(new_post_info);
}
if let Some(prepub_info) = get_latest_prepub(&volume.slug).await {
if let Some(prepub_info) = get_latest_prepub(&volume.slug) {
let prepub_post_info = PostInfo {
post_type: Some(PostType::Chapter),
part: Some(new_part_info),
@ -252,23 +231,22 @@ impl FetcherTrait for JNovelFetcher {
}
async fn get_latest_prepub(volume_slug: &str) -> Option<PostInfoInner> {
fn get_latest_prepub(volume_slug: &str) -> Option<PostInfoInner> {
let response = match HTTP_CLIENT
.get(api_url!() + "/volumes/" + volume_slug + "/parts?format=json")
.send()
.await
{
Ok(data) => match data.text().await {
Ok(data) => match data.text() {
Ok(data) => data,
Err(e) => {
let err_msg = format!("While getting latest PrePub: {e}");
error!(err_msg);
Logging::error(err_msg.as_str());
return None;
}
},
Err(e) => {
let err_msg = format!("{e}");
error!(err_msg);
Logging::error(err_msg.as_str());
return None;
}
};
@ -277,7 +255,7 @@ async fn get_latest_prepub(volume_slug: &str) -> Option<PostInfoInner> {
Ok(data) => data,
Err(e) => {
let err_msg = format!("{e}");
error!(err_msg);
Logging::error(err_msg.as_str());
return None;
}
};

View file

@ -1,4 +1,3 @@
use async_trait::async_trait;
use serde_derive::{Deserialize, Serialize};
use strum_macros::Display;
use crate::fetchers::Fetcher::Jnc;
@ -7,16 +6,15 @@ use crate::lemmy::{PostInfo};
pub mod jnovel;
#[async_trait]
pub(crate) trait FetcherTrait {
fn new() -> Self where Self: Sized;
async fn check_feed(&self) -> Result<Vec<PostInfo>, ()>;
fn check_feed(&self) -> Result<Vec<PostInfo>, ()>;
}
impl Fetcher {
pub(crate) async fn check_feed(&self) -> Result<Vec<PostInfo>, ()> {
pub(crate) fn check_feed(&self) -> Result<Vec<PostInfo>, ()> {
match self {
Jnc(fetcher) => fetcher.check_feed().await,
Jnc(fetcher) => fetcher.check_feed(),
/*default => {
let err_msg = format!("Fetcher {default} is not implemented");
error!(err_msg);

View file

@ -1,5 +1,5 @@
use std::cmp::Ordering;
use crate::config::{Config, PostBody, PostConfig, SeriesConfig};
use crate::config::{PostConfig, SeriesConfig};
use crate::{HTTP_CLIENT};
use lemmy_api_common::community::{ListCommunities, ListCommunitiesResponse};
use lemmy_api_common::lemmy_db_views::structs::PostView;
@ -9,28 +9,10 @@ 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 lemmy_db_schema::sensitive::SensitiveString;
use serde::{Deserialize, Serialize};
use systemd_journal_logger::connected_to_journal;
macro_rules! debug {
($msg:tt) => {
match connected_to_journal() {
true => log::debug!("[DEBUG] {}", $msg),
false => println!("[DEBUG] {}", $msg),
}
};
}
macro_rules! error {
($msg:tt) => {
match connected_to_journal() {
true => log::error!("[ERROR] {}", $msg),
false => eprintln!("[ERROR] {}", $msg),
}
};
}
use crate::logging::Logging;
use crate::settings::{BotSettings, PostBody};
pub(crate) struct Lemmy {
jwt_token: SensitiveString,
@ -107,7 +89,7 @@ impl PartialOrd for PartInfo {
}
}
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) enum PostType {
Chapter,
Volume
@ -207,26 +189,24 @@ impl PartialOrd for PostInfo {
impl Lemmy {
pub(crate) fn get_community_id(&self, name: &str) -> CommunityId {
*self.communities.get(name).expect("Given community is invalid")
*self.communities.get(name).expect(format!("Community '{name}' is invalid").as_str())
}
pub(crate) async fn new(config: &RwLock<Config>) -> Result<Self, ()> {
let read_config = config.read().expect("Read Lock Failed").clone();
pub(crate) fn new(config: &BotSettings) -> Result<Self, ()> {
let login_params = Login {
username_or_email: read_config.get_username(),
password: read_config.get_password(),
username_or_email: config.username(),
password: config.password(),
totp_2fa_token: None,
};
let response = match HTTP_CLIENT
.post(read_config.instance.to_owned() + "/api/v3/user/login")
.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);
Logging::error(err_msg.as_str());
return Err(());
}
};
@ -235,40 +215,41 @@ impl Lemmy {
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(),
instance: 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);
Logging::error("Login did not return JWT token. Are the credentials valid?");
Err(())
}
}
}
status => {
let err_msg = format!("Unexpected HTTP Status '{}' during Login", status);
error!(err_msg);
Logging::error(err_msg.as_str());
Err(())
}
}
}
pub(crate) async fn logout(&self) {
let _ = self.post_data_json("/api/v3/user/logout", &"").await;
pub fn logout(&self) {
let _ = self.post_data_json("/api/v3/user/logout", &"");
}
pub fn login(&self) {
}
pub(crate) async fn post(&self, post: CreatePost) -> Option<PostId> {
let response: String = match self.post_data_json("/api/v3/post", &post).await {
pub fn post(&self, post: CreatePost) -> Option<PostId> {
let response: String = match self.post_data_json("/api/v3/post", &post) {
Some(data) => data,
None => return None,
};
let json_data: PostView = match self.parse_json_map(&response).await {
let json_data: PostView = match self.parse_json_map(&response) {
Some(data) => data,
None => return None,
};
@ -276,12 +257,12 @@ impl Lemmy {
Some(json_data.post.id)
}
async fn feature(&self, params: FeaturePost) -> Option<PostView> {
let response: String = match self.post_data_json("/api/v3/post/feature", &params).await {
fn feature(&self, params: FeaturePost) -> Option<PostView> {
let response: String = match self.post_data_json("/api/v3/post/feature", &params) {
Some(data) => data,
None => return None,
};
let json_data: PostView = match self.parse_json_map(&response).await {
let json_data: PostView = match self.parse_json_map(&response) {
Some(data) => data,
None => return None,
};
@ -289,36 +270,36 @@ impl Lemmy {
Some(json_data)
}
pub(crate) async fn unpin(&self, post_id: PostId, location: PostFeatureType) -> Option<PostView> {
pub(crate) fn unpin(&self, post_id: PostId, location: PostFeatureType) -> Option<PostView> {
let pin_params = FeaturePost {
post_id,
featured: false,
feature_type: location,
};
self.feature(pin_params).await
self.feature(pin_params)
}
pub(crate) async fn pin(&self, post_id: PostId, location: PostFeatureType) -> Option<PostView> {
pub(crate) fn pin(&self, post_id: PostId, location: PostFeatureType) -> Option<PostView> {
let pin_params = FeaturePost {
post_id,
featured: true,
feature_type: location,
};
self.feature(pin_params).await
self.feature(pin_params)
}
pub(crate) async fn get_community_pinned(&self, community: CommunityId) -> Option<Vec<PostView>> {
pub(crate) fn get_community_pinned(&self, community: CommunityId) -> Option<Vec<PostView>> {
let list_params = GetPosts {
community_id: Some(community),
type_: Some(ListingType::Local),
..Default::default()
};
let response: String = match self.get_data_query("/api/v3/post/list", &list_params).await {
let response: String = match self.get_data_query("/api/v3/post/list", &list_params) {
Some(data) => data,
None => return None,
};
let json_data: GetPostsResponse = match self.parse_json(&response).await {
let json_data: GetPostsResponse = match self.parse_json(&response) {
Some(data) => data,
None => return None,
};
@ -331,17 +312,17 @@ impl Lemmy {
.collect())
}
pub(crate) async fn get_local_pinned(&self) -> Option<Vec<PostView>> {
pub(crate) fn get_local_pinned(&self) -> Option<Vec<PostView>> {
let list_params = GetPosts {
type_: Some(ListingType::Local),
..Default::default()
};
let response: String = match self.get_data_query("/api/v3/post/list", &list_params).await {
let response: String = match self.get_data_query("/api/v3/post/list", &list_params) {
Some(data) => data,
None => return None,
};
let json_data: GetPostsResponse = match self.parse_json(&response).await {
let json_data: GetPostsResponse = match self.parse_json(&response) {
Some(data) => data,
None => return None,
};
@ -354,17 +335,17 @@ impl Lemmy {
.collect())
}
pub(crate) async fn get_communities(&mut self) {
pub(crate) fn get_communities(&mut self) {
let list_params = ListCommunities {
type_: Some(ListingType::Local),
..Default::default()
};
let response: String = match self.get_data_query("/api/v3/community/list", &list_params).await {
let response: String = match self.get_data_query("/api/v3/community/list", &list_params) {
Some(data) => data,
None => return,
};
let json_data: ListCommunitiesResponse = match self.parse_json::<ListCommunitiesResponse>(&response).await {
let json_data: ListCommunitiesResponse = match self.parse_json::<ListCommunitiesResponse>(&response) {
Some(data) => data,
None => return,
};
@ -378,71 +359,71 @@ impl Lemmy {
self.communities = communities;
}
async fn post_data_json<T: Serialize>(&self, route: &str, json: &T ) -> Option<String> {
fn post_data_json<T: Serialize>(&self, route: &str, json: &T ) -> Option<String> {
let res = HTTP_CLIENT
.post(format!("{}{route}", &self.instance))
.bearer_auth(&self.jwt_token.to_string())
.json(&json)
.send()
.await;
self.extract_data(res).await
.send();
self.extract_data(res)
}
async fn get_data_query<T: Serialize>(&self, route: &str, param: &T ) -> Option<String> {
fn get_data_query<T: Serialize>(&self, route: &str, param: &T ) -> Option<String> {
let res = HTTP_CLIENT
.get(format!("{}{route}", &self.instance))
.bearer_auth(&self.jwt_token.to_string())
.query(&param)
.send()
.await;
self.extract_data(res).await
.send();
self.extract_data(res)
}
async fn extract_data(&self, response: Result<reqwest::Response, reqwest::Error>) -> Option<String> {
fn extract_data(&self, response: Result<reqwest::blocking::Response, reqwest::Error>) -> Option<String> {
match response {
Ok(data) => {
let msg = format!("Status Code: '{}'", data.status().clone());
Logging::debug(msg.as_str());
if data.status().is_success() {
match data.text().await {
match data.text() {
Ok(data) => Some(data),
Err(e) => {
let err_msg = format!("{e}");
error!(err_msg);
Logging::error(err_msg.as_str());
None
}
}
}
else {
let err_msg = format!("HTTP Request failed: {}", data.text().await.unwrap());
error!(err_msg);
let err_msg = format!("HTTP Request failed: {}", data.text().unwrap());
Logging::error(err_msg.as_str());
None
}
},
Err(e) => {
let err_msg = format!("{e}");
error!(err_msg);
Logging::error(err_msg.as_str());
None
}
}
}
async fn parse_json<'a, T: Deserialize<'a>>(&self, response: &'a str) -> Option<T> {
fn parse_json<'a, T: Deserialize<'a>>(&self, response: &'a str) -> Option<T> {
match serde_json::from_str::<T>(response) {
Ok(data) => Some(data),
Err(e) => {
let err_msg = format!("while parsing JSON: {e} ");
error!(err_msg);
Logging::error(err_msg.as_str());
None
}
}
}
async fn parse_json_map<'a, T: Deserialize<'a>>(&self, response: &'a str) -> Option<T> {
debug!(response);
fn parse_json_map<'a, T: Deserialize<'a>>(&self, response: &'a str) -> Option<T> {
Logging::debug(response);
match serde_json::from_str::<HashMap<&str, T>>(response) {
Ok(mut data) => Some(data.remove("post_view").expect("Element should be present")),
Err(e) => {
let err_msg = format!("while parsing JSON HashMap: {e}");
error!(err_msg);
Logging::error(err_msg.as_str());
None
}
}

110
src/logging.rs Normal file
View file

@ -0,0 +1,110 @@
use std::collections::VecDeque;
use chrono::{DateTime, Utc};
use log::Level;
use parking_lot::Mutex;
use systemd_journal_logger::connected_to_journal;
fn mem_log(level: Level, msg: Option<String>) -> VecDeque<LogEvent> {
static MEM_LOG_DEBUG: Mutex<VecDeque<LogEvent>> = Mutex::new(VecDeque::new());
static MEM_LOG_INFO: Mutex<VecDeque<LogEvent>> = Mutex::new(VecDeque::new());
static MEM_LOG_WARN: Mutex<VecDeque<LogEvent>> = Mutex::new(VecDeque::new());
static MEM_LOG_ERROR: Mutex<VecDeque<LogEvent>> = Mutex::new(VecDeque::new());
let max_len: i8;
let mut list = match level {
Level::Debug => {
max_len = 10;
MEM_LOG_DEBUG.lock()
},
Level::Info => {
max_len = 20;
MEM_LOG_INFO.lock()
},
Level::Warn => {
max_len = 40;
MEM_LOG_WARN.lock()
},
Level::Error => {
max_len = -1;
MEM_LOG_ERROR.lock()
},
Level::Trace => {
max_len = 10;
MEM_LOG_DEBUG.lock()
}
};
if let Some(msg) = msg {
let now = Utc::now();
let log_event = LogEvent::new(now, msg);
list.push_back(log_event);
if max_len != -1 {
while list.len() > max_len as usize {
list.pop_front();
}
}
}
list.clone()
}
pub struct Logging {
}
impl Logging {
pub fn debug(msg: &str) {
let msg = format!("[DEBUG] {msg}");
match connected_to_journal() {
true => log::debug!("{msg}"),
false => println!("{msg}"),
}
mem_log(Level::Debug, Some(msg));
}
pub fn info(msg: &str) {
let msg = format!("[INFO] {msg}");
match connected_to_journal() {
true => log::info!("{msg}"),
false => println!("{msg}"),
}
mem_log(Level::Info, Some(msg));
}
pub fn warn(msg: &str) {
let msg = format!("[WARN] {msg}");
match connected_to_journal() {
true => log::warn!("{msg}"),
false => println!("{msg}"),
}
mem_log(Level::Warn, Some(msg));
}
pub fn error(msg: &str) {
let msg = format!("[ERROR] {msg}");
match connected_to_journal() {
true => log::error!("{msg}"),
false => eprintln!("{msg}"),
}
mem_log(Level::Error, Some(msg));
}
pub fn get_mem_log(level: Level) -> VecDeque<LogEvent> {
mem_log(level, None)
}
}
#[derive(Clone)]
pub(crate) struct LogEvent {
pub date: DateTime<Utc>,
pub text: String,
}
impl LogEvent {
pub fn new(time: DateTime<Utc>, message: String) -> Self {
Self {
date: time,
text: message
}
}
}

View file

@ -1,15 +1,26 @@
use std::env;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread::{sleep, spawn};
use atomic_enum::atomic_enum;
use chrono::{Duration};
use log::{LevelFilter};
use once_cell::sync::Lazy;
use reqwest::Client;
use parking_lot::{RwLock};
use reqwest::blocking::Client;
use systemd_journal_logger::{JournalLog};
use crate::bot::Bot;
use crate::HealthSignal::RequestAlive;
use crate::logging::Logging;
use crate::settings::ApplicationSettings;
mod bot;
mod config;
mod lemmy;
mod post_history;
mod fetchers;
mod logging;
mod settings;
pub static HTTP_CLIENT: Lazy<Client> = Lazy::new(|| {
Client::builder()
@ -19,13 +30,13 @@ pub static HTTP_CLIENT: Lazy<Client> = Lazy::new(|| {
.expect("build client")
});
#[tokio::main]
async fn main() {
fn main() {
JournalLog::new()
.expect("Systemd-Logger crate error")
.install()
.expect("Systemd-Logger crate error");
match std::env::var("LOG_LEVEL") {
match env::var("LOG_LEVEL") {
Ok(level) => {
match level.as_str() {
"debug" => log::set_max_level(LevelFilter::Debug),
@ -36,6 +47,71 @@ async fn main() {
_ => log::set_max_level(LevelFilter::Info),
}
let mut bot = Bot::new();
bot.run().await;
let mut settings = Arc::new(RwLock::new(ApplicationSettings::new()));
env::set_var("USING_DB", settings.read().is_settings_source_database().to_string());
if settings.read().is_settings_source_database() {
Logging::info("Database Usage enabled");
}
else {
Logging::info("Operating in Single Bot Mode")
}
let mut health_signals: Vec<Arc<AtomicHealthSignal>> = vec![];
let mut update_pending: Arc<AtomicBool> = Arc::new(AtomicBool::new(false));
settings.read().bot_settings().read().iter().for_each(|(id, bot_settings)| {
let health_signal = Arc::new(AtomicHealthSignal::new(HealthSignal::RequestAlive));
health_signals.push(health_signal.clone());
let bot_settings = bot_settings.clone();
let bot_id = *id;
let series_settings = settings.read().series_settings().clone();
let bot_settings_map = settings.read().bot_settings().clone();
let update_pending = update_pending.clone();
spawn(move || {
let mut bot = Bot::new(health_signal, bot_id, bot_settings);
bot.run(series_settings, bot_settings_map, update_pending);
});
});
loop {
update_pending.store(true, Ordering::SeqCst);
settings.write().update();
Logging::info("Configuration Updated");
update_pending.store(false, Ordering::SeqCst);
health_signals.iter().for_each(|signal| {
if signal.load(Ordering::SeqCst) == RequestAlive {
// at least one thread died, close all other threads
Logging::warn("A Bot died, restarting application.");
health_signals.iter().for_each(|signal| {
signal.store(HealthSignal::RequestStop, Ordering::SeqCst);
});
loop {
if health_signals.iter().all(|signal| {
signal.load(Ordering::SeqCst) == HealthSignal::ConfirmStop
}) {
Logging::info("All Threads successfully stopped");
break;
}
else {
sleep(Duration::milliseconds(100).to_std().unwrap());
}
}
}
});
sleep(Duration::seconds(10).to_std().unwrap());
}
}
#[atomic_enum]
#[derive(PartialEq)]
enum HealthSignal {
RequestAlive,
ConfirmAlive,
RequestStop,
ConfirmStop,
}

View file

142
src/settings/mod.rs Normal file
View file

@ -0,0 +1,142 @@
use std::any::{Any, TypeId};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use chrono::{DateTime, Utc};
use lemmy_db_schema::sensitive::SensitiveString;
use parking_lot::RwLock;
use serde_derive::{Deserialize, Serialize};
use crate::fetchers::Fetcher;
use crate::lemmy::PostType;
use crate::settings::toml::database::DatabaseTomlSettings;
use crate::settings::toml::TomlSettings;
pub mod database;
pub mod toml;
pub trait SettingsSource {
fn update(&mut self);
fn bot_settings(&self) -> HashMap<u16, BotSettings>;
fn series_settings(&self) -> HashMap<u16, SeriesSettings>;
}
pub struct ApplicationSettings {
settings_source: Box<dyn SettingsSource>,
bot_settings: Arc<RwLock<HashMap<u16, BotSettings>>>,
series_settings: Arc<RwLock<HashMap<u16, SeriesSettings>>>,
}
impl ApplicationSettings {
pub fn new() -> Self {
let toml_settings = TomlSettings::load();
let settings_source: Box<dyn SettingsSource>;
if let Some(database) = toml_settings.database {
settings_source = Box::new(database) as Box<dyn SettingsSource>;
}
else if let Some(single_bot) = toml_settings.single_bot {
settings_source = Box::new(single_bot) as Box<dyn SettingsSource>;
}
else {
panic!("Invalid TOML Configuration: No valid Settings Source provided!");
}
let bot_settings = Arc::new(RwLock::new(settings_source.bot_settings()));
let series_settings = Arc::new(RwLock::new(settings_source.series_settings()));
ApplicationSettings {
settings_source,
bot_settings,
series_settings,
}
}
pub fn is_settings_source_database(&self) -> bool {
self.settings_source.type_id() == TypeId::of::<DatabaseTomlSettings>()
}
pub fn update(&mut self) {
self.settings_source.update();
*self.bot_settings.write() = self.settings_source.bot_settings();
*self.series_settings.write() = self.settings_source.series_settings();
}
pub fn bot_settings(&self) -> Arc<RwLock<HashMap<u16, BotSettings>>> {
self.bot_settings.clone()
}
pub fn series_settings(&self) -> Arc<RwLock<HashMap<u16, SeriesSettings>>> {
self.series_settings.clone()
}
}
#[derive(Clone)]
pub struct StatusPostSettings {
url: String,
interval: Duration,
}
#[derive(Clone)]
pub struct BotSettings {
id: u16,
series_ids: Vec<u16>,
instance: String,
username: SensitiveString,
password: SensitiveString,
status_post_settings: Option<StatusPostSettings>,
}
impl BotSettings {
pub fn instance(&self) -> String {
self.instance.clone()
}
pub fn username(&self) -> SensitiveString {
self.username.clone()
}
pub fn password(&self) -> SensitiveString {
self.password.clone()
}
pub fn status_post_url(&self) -> Option<String> {
match &self.status_post_settings {
Some(settings) => Some(settings.url.clone()),
None => None
}
}
pub fn status_post_interval(&self) -> Option<Duration> {
match &self.status_post_settings {
Some(settings) => Some(settings.interval),
None => None
}
}
}
pub struct SeriesSettings {
id: u16,
post_settings: HashMap<PostType, PostSettings>,
fetcher: Fetcher,
fetch_interval: Duration,
last_fetched: DateTime<Utc>,
}
impl SeriesSettings {
pub fn check(&self) {
}
}
pub struct PostSettings {
r#type: PostType,
community: String,
body_type: PostBody,
pin_community: bool,
pin_instance: bool
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(tag = "body_type", content = "body_content")]
pub(crate) enum PostBody {
None,
Description,
Custom(String),
}

87
src/settings/toml/bot.rs Normal file
View file

@ -0,0 +1,87 @@
use std::collections::HashMap;
use std::time::Duration;
use chrono::Utc;
use lemmy_db_schema::sensitive::SensitiveString;
use serde_derive::{Deserialize, Serialize};
use crate::config::SeriesConfig;
use crate::fetchers::{Fetcher, FetcherTrait};
use crate::fetchers::jnovel::JNovelFetcher;
use crate::lemmy::PostType;
use crate::settings::{BotSettings, PostSettings, SeriesSettings, SettingsSource, StatusPostSettings};
use crate::settings::toml::TomlSettings;
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SingleBotTomlSettings {
instance: String,
username: SensitiveString,
password: SensitiveString,
status_post_url: Option<String>,
status_post_interval: Option<u32>,
pub protected_communities: Vec<String>,
series: Vec<SeriesConfig>,
fetch_interval: u32,
}
impl SettingsSource for SingleBotTomlSettings {
fn update(&mut self) {
let toml_settings = TomlSettings::load();
if let Some(single_bot) = toml_settings.single_bot {
*self = single_bot;
}
else {
panic!("No valid 'single_bot' configuration found. Hot-switching between database and single_bot mode is not currently supported.");
}
}
fn bot_settings(&self) -> HashMap<u16, BotSettings> {
let mut map = HashMap::new();
map.insert(0, BotSettings {
id: 0,
series_ids: vec![0],
instance: self.instance.clone(),
username: self.username.clone(),
password: self.password.clone(),
status_post_settings: Some(StatusPostSettings {
url: self.status_post_url.clone().unwrap(),
interval: Duration::from_secs(self.status_post_interval.clone().unwrap() as u64)
}),
});
map
}
fn series_settings(&self) -> HashMap<u16, SeriesSettings> {
let mut map = HashMap::new();
self.series.iter().enumerate().for_each(|(id, series)| {
let mut fetcher = JNovelFetcher::new();
fetcher.set_series(series.slug.clone());
fetcher.set_part_option(series.parted);
let mut post_settings = HashMap::new();
post_settings.insert(PostType::Chapter, PostSettings {
r#type: PostType::Chapter,
community: series.prepub_community.name.clone(),
body_type: series.prepub_community.post_body.clone(),
pin_community: series.prepub_community.pin_settings.pin_new_post_community,
pin_instance: series.prepub_community.pin_settings.pin_new_post_local,
});
post_settings.insert(PostType::Volume, PostSettings {
r#type: PostType::Volume,
community: series.volume_community.name.clone(),
body_type: series.volume_community.post_body.clone(),
pin_community: series.volume_community.pin_settings.pin_new_post_community,
pin_instance: series.volume_community.pin_settings.pin_new_post_local,
});
map.insert(id as u16, SeriesSettings {
id: id as u16,
post_settings,
fetcher: Fetcher::Jnc(fetcher),
fetch_interval: Duration::from_secs(self.fetch_interval as u64),
last_fetched: Utc::now() - Duration::from_secs(self.fetch_interval as u64),
});
});
map
}
}

View file

@ -0,0 +1,22 @@
use std::collections::HashMap;
use serde_derive::{Deserialize, Serialize};
use crate::settings::{BotSettings, SeriesSettings, SettingsSource};
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct DatabaseTomlSettings {
}
impl SettingsSource for DatabaseTomlSettings {
fn update(&mut self) {
todo!()
}
fn bot_settings(&self) -> HashMap<u16, BotSettings> {
todo!()
}
fn series_settings(&self) -> HashMap<u16, SeriesSettings> {
todo!()
}
}

37
src/settings/toml/mod.rs Normal file
View file

@ -0,0 +1,37 @@
pub mod database;
pub mod bot;
use std::time::Duration;
use lemmy_db_schema::sensitive::SensitiveString;
use serde_derive::{Deserialize, Serialize};
use crate::config::SeriesConfig;
use crate::settings::BotSettings;
use crate::settings::toml::bot::SingleBotTomlSettings;
use crate::settings::toml::database::DatabaseTomlSettings;
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct TomlSettings {
pub database: Option<DatabaseTomlSettings>,
pub single_bot: Option<SingleBotTomlSettings>,
}
impl TomlSettings {
pub(crate) fn load() -> Self {
let mut cfg: Self = match confy::load(env!("CARGO_PKG_NAME"), "config") {
Ok(data) => data,
Err(e) => panic!("config.toml not found: {e}"),
};
cfg
}
}
impl Default for TomlSettings {
fn default() -> Self {
TomlSettings {
database: None,
single_bot: None,
}
}
}