use std::path::{Path, PathBuf}; use std::sync::Arc; use elefren::{scopes, FediClient, Registration, Scopes}; use futures::StreamExt; use crate::error::GroupError; use crate::group_handler::{GroupHandle, GroupInternal}; pub mod common_config; pub mod group_config; use crate::tr::TranslationTable; pub use common_config::CommonConfig; pub use group_config::GroupConfig; #[derive(Debug, Default)] pub struct ConfigStore { store_path: PathBuf, groups_path: PathBuf, locales_path: PathBuf, config: CommonConfig, } #[derive(Debug)] pub struct NewGroupOptions { pub server: String, pub acct: String, } #[derive(Debug)] pub struct StoreOptions { pub store_dir: String, } const DEFAULT_LOCALE_NAME: &str = "en"; const DEFAULT_LOCALE_JSON: &str = include_str!("../../locales/en.json"); impl ConfigStore { /// Create a new instance of the store. /// If a path is given, it will try to load the content from a file. pub async fn load_from_fs(options: StoreOptions) -> Result { let given_path: &Path = options.store_dir.as_ref(); let mut common_file: Option = None; let base_dir: PathBuf; if given_path.is_file() { if given_path.extension().unwrap_or_default().to_string_lossy() == "json" { // this is a groups.json file common_file = Some(given_path.to_owned()); base_dir = given_path .parent() .ok_or_else(|| GroupError::BadConfig("no parent dir".into()))? .to_owned(); } else { return Err(GroupError::BadConfig("bad config file, should be JSON".into())); } } else if given_path.is_dir() { let cf = given_path.join("groups.json"); if cf.is_file() { common_file = Some(cf); } base_dir = given_path.to_owned(); } else { return Err(GroupError::BadConfig("bad config file/dir".into())); } if !base_dir.is_dir() { return Err(GroupError::BadConfig("base dir does not exist".into())); } let config: CommonConfig = if let Some(cf) = &common_file { debug!("Loading common config from {}", cf.display()); let f = tokio::fs::read(&cf).await?; json5::from_str(&String::from_utf8_lossy(&f))? } else { debug!("No common config file, using defaults"); CommonConfig::default() }; debug!("Using common config:\n{:#?}", config); let groups_path = if config.groups_dir.starts_with('/') { PathBuf::from(&config.groups_dir) } else { base_dir.join(&config.groups_dir) }; if !groups_path.exists() { debug!("Creating groups directory"); tokio::fs::create_dir_all(&groups_path).await?; } let locales_path = if config.locales_dir.starts_with('/') { PathBuf::from(&config.locales_dir) } else { base_dir.join(&config.locales_dir) }; // warn, this is usually not a good idea beside for testing if config.max_catchup_notifs == 0 { warn!("Missed notifications catch-up is disabled!"); } if config.max_catchup_statuses == 0 { warn!("Missed statuses catch-up is disabled!"); } Ok(Self { store_path: base_dir.to_owned(), groups_path, locales_path, config, }) } /// Spawn a new group pub async fn auth_new_group(&self, opts: NewGroupOptions) -> Result<(), GroupError> { let registration = Registration::new(&opts.server) .client_name("group-actor") .force_login(true) .scopes(make_scopes()) .build() .await?; println!("--- Authenticating NEW bot user @{} ---", opts.acct); let client = elefren::helpers::cli::authenticate(registration).await?; let appdata = client.data.clone(); let group_dir = self.groups_path.join(&opts.acct); GroupConfig::initialize_by_appdata(opts.acct.clone(), appdata, group_dir).await?; // save & persist match client.verify_credentials().await { Ok(account) => { info!( "Group account verified: @{}, \"{}\"", account.acct, account.display_name ); } Err(e) => { error!("Group @{} auth error: {}", opts.acct, e); return Err(e.into()); } }; Ok(()) } /// Re-auth an existing group pub async fn reauth_group(&self, acct: &str) -> Result<(), GroupError> { let group_dir = self.groups_path.join(&acct); let mut config = GroupConfig::from_dir(group_dir, &self.config).await?; println!("--- Re-authenticating bot user @{} ---", acct); let registration = Registration::new(config.get_appdata().base.to_string()) .client_name("group-actor") .force_login(true) .scopes(make_scopes()) .build() .await?; let client = elefren::helpers::cli::authenticate(registration).await?; println!("Auth complete"); let appdata = client.data.clone(); config.set_appdata(appdata); config.save_if_needed(true).await?; let _group_account = match client.verify_credentials().await { Ok(account) => { info!( "Group account verified: @{}, \"{}\"", account.acct, account.display_name ); account } Err(e) => { error!("Group @{} auth error: {}", acct, e); return Err(e.into()); } }; Ok(()) } pub async fn find_locales(&mut self) { // Load the default locale, it will be used as fallback to fill-in missing keys self.load_locale(DEFAULT_LOCALE_NAME, DEFAULT_LOCALE_JSON, true); if !self.locales_path.is_dir() { debug!("No locales path set!"); return; } let entries = match std::fs::read_dir(&self.locales_path) { Ok(ee) => ee, Err(e) => { warn!("Error listing locales: {}", e); return; } }; for e in entries { if let Ok(e) = e { let path = e.path(); if path.is_file() && path.extension().unwrap_or_default().to_string_lossy() == "json" { let filename = path.file_name().unwrap_or_default().to_string_lossy(); debug!("Loading locale {}", filename); match tokio::fs::read(&path).await { Ok(f) => { let locale_name = path.file_stem().unwrap_or_default().to_string_lossy(); self.load_locale(&locale_name, &String::from_utf8_lossy(&f), false); } Err(e) => { error!("Failed to read locale file {}: {}", path.display(), e); } } } } } } fn load_locale(&mut self, locale_name: &str, locale_json: &str, is_default: bool) { if let Ok(mut tr) = json5::from_str::(locale_json) { debug!("Loaded locale: {}", locale_name); if !is_default { let def_tr = self.config.tr.get(DEFAULT_LOCALE_NAME).expect("Default locale not loaded!"); for (k, v) in def_tr.entries() { if !tr.translation_exists(k) { if self.config.validate_locales { warn!( "locale \"{}\" is missing \"{}\", default: {:?}", locale_name, k, def_tr.get_translation_raw(k).unwrap() ); } tr.add_translation(k, v); } } } self.config.tr.insert(locale_name.to_owned(), tr); } else { error!("Failed to parse locale {}", locale_name); } } /// Spawn existing group using saved creds pub async fn spawn_groups(self) -> Result, GroupError> { info!("Starting group services for groups in {}", self.groups_path.display()); let dirs = std::fs::read_dir(&self.groups_path)?; let config = Arc::new(self.config); // Connect in parallel Ok(futures::stream::iter(dirs) .map(|entry_maybe: Result| async { match entry_maybe { Ok(entry) => { let gc = GroupConfig::from_dir(entry.path(), &config).await.ok()?; if !gc.is_enabled() { debug!("Group @{} is DISABLED", gc.get_acct()); return None; } debug!("Connecting to @{}", gc.get_acct()); let client = FediClient::from(gc.get_appdata().clone()); let my_account = match client.verify_credentials().await { Ok(account) => { info!( "Group account verified: @{}, \"{}\"", account.acct, account.display_name ); account } Err(e) => { error!("Group @{} auth error: {}", gc.get_acct(), e); return None; } }; Some(GroupHandle { group_account: my_account, client, config: gc, cc: config.clone(), internal: GroupInternal::default(), }) } Err(e) => { error!("{}", e); None } } }) .buffer_unordered(8) .collect::>() .await .into_iter() .flatten() .collect()) } pub async fn group_exists(&self, acct: &str) -> bool { self.store_path.join(acct).join("config.json").is_file() } } fn make_scopes() -> Scopes { Scopes::read_all() | Scopes::write(scopes::Write::Statuses) | Scopes::write(scopes::Write::Media) | Scopes::write(scopes::Write::Follows) }