#![feature(slice_group_by)] mod utils; mod paheko; mod helloasso; mod sync_helloasso; mod sync_csv; mod sync_paheko; use thiserror::Error; use anyhow::{Context, Result, anyhow}; use chrono::prelude::{NaiveDate, Datelike}; use strum::Display; use serde::{Serialize, Deserialize}; use url::Url; use fully_pub::fully_pub; /// permanent config to store long-term config /// used to ingest env settings /// config loaded from env variables #[derive(Deserialize, Serialize, Debug)] #[fully_pub] struct Config { helloasso_proxy: Option<String>, helloasso_email: String, helloasso_password: String, helloasso_organization_slug: String, helloasso_form_name: String, paheko_proxy: Option<String>, paheko_base_url: String, paheko_client_id: String, paheko_client_secret: String, paheko_target_activity_name: String, // paheko_accounting_year_id: u64, } // start user cache management use std::fs; #[derive(Serialize, Deserialize, Debug)] #[fully_pub] struct UserCache { helloasso_session: Option<helloasso::WebSession> } #[derive(Display, Debug, Error)] #[strum(serialize_all = "snake_case")] enum LoadError { XDG, Fs, FailedToParse, FailedToEncode, FailedToCreate, FailedToWrite } const APP_USER_AGENT: &str = "helloasso_paheko_adapter"; fn write_user_cache(cache: &UserCache) -> Result<(), LoadError> { let xdg_dirs = xdg::BaseDirectories::with_prefix(env!("CARGO_PKG_NAME")) .map_err(|_e| { LoadError::XDG })?; let user_cache_path = xdg_dirs.place_cache_file("session.json").map_err(|_e| { LoadError::FailedToCreate })?; let encoded_cache = serde_json::to_string(&cache).map_err(|_e| { LoadError::FailedToEncode })?; fs::write(user_cache_path, encoded_cache.as_str()).map_err(|_e| { LoadError::FailedToWrite })?; Ok(()) } fn load_user_cache() -> Result<UserCache, LoadError> { let xdg_dirs = xdg::BaseDirectories::with_prefix(env!("CARGO_PKG_NAME")) .map_err(|_e| { LoadError::XDG })?; let user_cache_path = xdg_dirs.get_cache_file("session.json"); if !user_cache_path.exists() { let default_cache = UserCache { helloasso_session: None }; write_user_cache(&default_cache)?; } let session_content = fs::read_to_string(user_cache_path).map_err(|_e| { LoadError::Fs })?; let cache: UserCache = serde_json::from_str(&session_content).map_err(|_e| { LoadError::FailedToParse })?; Ok(cache) } // TODO: find a better way to have the logic implemented async fn get_auth_client_from_cache( user_cache: &mut UserCache, ha_client: &mut helloasso::Client, login_payload: helloasso::LoginPayload ) -> Result<helloasso::AuthentifiedClient> { async fn login( user_cache: &mut UserCache, ha_client: &mut helloasso::Client, login_payload: helloasso::LoginPayload ) -> Result<helloasso::AuthentifiedClient> { let auth_client = ha_client.login( login_payload ).await.context("Failed to login")?; user_cache.helloasso_session = Some(auth_client.session.clone()); write_user_cache(&user_cache).expect("unable to write user cache"); println!("Logged in and wrote token to cache"); Ok(auth_client) } match &user_cache.helloasso_session { Some(cached_session) => { let auth_client = ha_client.authentified_client(cached_session.clone()); if !auth_client.verify_auth().await? { println!("Need to relog, token invalid"); return login(user_cache, ha_client, login_payload).await } println!("Used anterior token"); Ok(auth_client) }, None => { println!("First time login"); login(user_cache, ha_client, login_payload).await } } } fn parse_normalize_phone(phone_number_opt: Option<String>) -> Option<String> { let number_raw = phone_number_opt?; let parsed = match phonenumber::parse(Some(phonenumber::country::Id::FR), number_raw) { Ok(r) => { r }, Err(_e) => { return None; } }; Some(parsed.to_string()) } fn normalize_str(subject: String) -> String { subject.trim().replace("\n", ";").to_string() } /// remove year precision to comply with GDPR eu rules fn parse_and_get_birthday_year(raw_date: String) -> Option<u32> { let d_res = NaiveDate::parse_from_str(raw_date.trim(), "%d/%m/%Y"); let d = d_res.ok()?; d.year().try_into().ok() } fn get_proxy_from_url(proxy_url: &Option<String>) -> Result<Option<reqwest::Proxy>> { Ok(match proxy_url { Some(p) => Some(reqwest::Proxy::all(p) .context("Expected to build Proxy from paheko_proxy config value")?), None => None }) } async fn launch_adapter() -> Result<()> { dotenvy::dotenv()?; let config: Config = envy::from_env().context("Failed to load env vars")?; let mut user_cache = load_user_cache().context("Failed to load user cache")?; if !&config.paheko_base_url.ends_with("/") { return Err(anyhow!("Invalid paheko base_url, it must end with a slash")) } let mut paheko_client: paheko::Client = paheko::Client::new(paheko::ClientConfig { base_url: Url::parse(&config.paheko_base_url).expect("Expected paheko base url to be a valid URL"), proxy: get_proxy_from_url(&config.paheko_proxy)?, user_agent: APP_USER_AGENT.to_string() }); let paheko_credentials = paheko::Credentials { client_id: config.paheko_client_id.clone(), client_secret: config.paheko_client_secret.clone() }; let paheko_client: paheko::AuthentifiedClient = paheko_client.login(paheko_credentials).await?; sync_helloasso::sync_helloasso(&paheko_client, &config, &mut user_cache).await?; // sync_csv::sync(&paheko_client, &config, &mut user_cache).await?; Ok(()) } #[tokio::main] async fn main() { // TODO: add argument parser to have handle config file let res = launch_adapter().await; match res { Err(err) => { eprintln!("Program failed, details bellow"); eprintln!("{:?}", err); }, Ok(()) => { eprintln!("Program done"); } } }