use anyhow::{Context, Result, anyhow}; use url::Url; use chrono::prelude::{NaiveDate, DateTime, Utc}; use strum::Display; use serde::{Serialize, Deserialize}; use std::collections::HashSet; use rand::{thread_rng, Rng}; use phonenumber; /// ID #[derive(Clone, Deserialize, Serialize, Debug, PartialEq, Eq, Hash)] pub struct Id(pub u64); impl Id { pub fn to_string(&self) -> String { format!("{:x}", self.0) } } impl Into<String> for Id { fn into(self) -> String { format!("{:x}", self.0) } } pub fn generate_id() -> Id { Id(thread_rng().gen()) } /// permanent config to store long-term config /// used to ingest env settings #[derive(Deserialize, Serialize, Debug)] struct Config { helloasso_email: String, helloasso_password: String } #[derive(Serialize, Debug)] struct LoginPayload { email: String, password: String } use thiserror::Error; #[derive(Error, Debug)] enum APIClientError { #[error("Received non-normal status code from API")] InvalidStatusCode } static APP_USER_AGENT: &str = concat!( env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"), ); // start user cache management use std::fs; #[derive(Serialize, Deserialize, Debug)] struct UserCache { helloasso_session: Option<HelloassoSession> } #[derive(Display, Debug, Error)] #[strum(serialize_all = "snake_case")] enum LoadError { XDG, Fs, FailedToParse, FailedToEncode, FailedToCreate, FailedToWrite } 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) } #[derive(Clone, Serialize, Deserialize, Debug)] struct HelloassoSession { jwt: String } enum LoginError { TransportFailure(reqwest::Error) } #[derive(Debug)] struct HelloassoClient { client: reqwest::Client, base_url: Url, } impl Default for HelloassoClient { fn default() -> Self { HelloassoClient { client: HelloassoClient::get_base_client_builder() .build() .expect("reqwest client to be built"), base_url: Url::parse("https://api.helloasso.com/v5/") .expect("Valid helloasso API base URL") } } } impl HelloassoClient { fn get_base_client_builder() -> reqwest::ClientBuilder { let mut default_headers = reqwest::header::HeaderMap::new(); default_headers.insert("Accept", "application/json".parse().unwrap()); let proxy = reqwest::Proxy::https("https://localhost:8999").unwrap(); reqwest::Client::builder() .proxy(proxy) .default_headers(default_headers) } async fn login(&mut self, payload: LoginPayload) -> Result<AuthentifiedHelloassoClient> { let mut login_commons_headers = reqwest::header::HeaderMap::new(); login_commons_headers.insert( "Origin", "https://auth.helloasso.com".parse().expect("Header value to be OK") ); let res = self.client.get(self.base_url.join("auth/antiforgerytoken")?) .headers(login_commons_headers.clone()) .send().await?; let antiforgerytoken: String = res.json().await?; let res = self.client.post(self.base_url.join("auth/login")?) .json(&payload) .headers(login_commons_headers.clone()) .header("x-csrf-token", antiforgerytoken) .send() .await?; if res.status() != 200 { return Err(anyhow!("Unexpected status code from login")); } fn get_jwt_from_cookies_headers(headers: &reqwest::header::HeaderMap) -> Option<String> { for (name_opt, value_raw) in headers { let name = String::from(name_opt.as_str()); if name.to_lowercase() != "set-cookie" { continue } let value = String::from(value_raw.to_str().unwrap()); if value.starts_with("tm5-HelloAsso") { let jwt = value.split("tm5-HelloAsso=").nth(1)?.split(";").nth(0)?.trim().to_string(); return Some(jwt); } } None } let jwt = get_jwt_from_cookies_headers(&res.headers()) .context("Failed to find or parse JWT from login response")?; let session = HelloassoSession { jwt }; Ok(self.authentified_client(session)) } fn authentified_client(&self, session: HelloassoSession) -> AuthentifiedHelloassoClient { AuthentifiedHelloassoClient::new(self.base_url.clone(), session) } } #[derive(Debug, Clone)] struct AuthentifiedHelloassoClient { session: HelloassoSession, client: reqwest::Client, base_url: Url } #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct PaginationMeta { continuation_token: String, page_index: u64, page_size: u64, total_count: u64, total_pages: u64 } #[derive(Debug, Serialize, Deserialize)] struct PaginationCapsule { data: serde_json::Value, pagination: PaginationMeta } #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct CustomFieldAnswer { answer: String, id: u64, name: String // missing type, it's probably always TextInput, if not, serde will fail to parse } #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct UserDetails { country: String, email: String, first_name: String, last_name: String } // #[derive(Debug, Serialize, Deserialize)] // #[serde(rename_all = "camelCase")] // struct OrderDetails { // date: // form_ // } impl AuthentifiedHelloassoClient { /// each time we need to change the token, we will need to rebuild the client fn new(base_url: Url, session: HelloassoSession) -> Self { let mut auth_headers = reqwest::header::HeaderMap::new(); auth_headers.insert("Authorization", format!("Bearer {}", session.jwt).parse().unwrap()); AuthentifiedHelloassoClient { base_url, session, client: HelloassoClient::get_base_client_builder() .default_headers(auth_headers) .build() .expect("reqwest client to be built") } } async fn verify_auth(&self) -> Result<bool> { let res = self.client .get(self.base_url.join("agg/user")?) .send().await?; return Ok(res.status() == 200); } async fn get_user_details(&self) -> Result<()> { let res = self.client .get(self.base_url.join("agg/user")?) .send().await?; if res.status() != 200 { return Err(APIClientError::InvalidStatusCode.into()); } let user_details: serde_json::Value = res.json().await?; dbg!(user_details); Ok(()) } async fn fetch(&self, path: String) -> Result<serde_json::Value> { let res = self.client .get(self.base_url.join(path.as_str())?) .send().await?; if res.status() != 200 { return Err(APIClientError::InvalidStatusCode.into()); } let details: serde_json::Value = res.json().await?; // handle pagination // merge into "data", "pagination" is the key that hold details Ok(details) } async fn fetch_with_pagination(&self, path: String) -> Result<Vec<serde_json::Value>> { let mut data: Vec<serde_json::Value> = vec![]; let mut continuation_token: Option<String> = None; loop { let mut url = self.base_url.join(path.as_str())?; if let Some(token) = &continuation_token { url.query_pairs_mut().append_pair("continuationToken", token); } let res = self.client .get(url) .send().await?; if res.status() != 200 { return Err(APIClientError::InvalidStatusCode.into()); } let capsule: PaginationCapsule = res.json().await?; // handle pagination // merge into "data", "pagination" is the key that hold details let page_items = match capsule.data { serde_json::Value::Array(inner) => inner, _ => { return Err(anyhow!("Unexpected json value in data bundle")); } }; if page_items.len() == 0 { return Ok(data); } data.extend(page_items); if capsule.pagination.page_index == capsule.pagination.total_pages { return Ok(data); } continuation_token = Some(capsule.pagination.continuation_token); } } fn organization(&self, slug: &str) -> Organization { Organization { client: self.clone(), slug: slug.to_string() } } } #[derive(Debug, Clone)] struct Organization { client: AuthentifiedHelloassoClient, slug: String } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] enum MembershipMode { #[serde(rename = "Individuel")] Individual, #[serde(rename = "Couple")] Couple, #[serde(rename = "Individuel bienfaiteur")] BenefactorIndividual, #[serde(rename = "Couple bienfaiteur")] BenefactorCouple, } #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct FormAnswer { amount: u64, #[serde(rename = "name")] mode: MembershipMode, #[serde(rename = "payer")] user: UserDetails, id: u64, custom_fields: Vec<CustomFieldAnswer> } impl Organization { async fn get_details(&self) -> Result<serde_json::Value> { let details = self.client.fetch(format!("organizations/{}", self.slug)).await?; Ok(details) } async fn get_form_answers(&self, form_slug: String) -> Result<Vec<FormAnswer>> { let data = self.client.fetch_with_pagination( format!("organizations/{}/forms/Membership/{}/participants?withDetails=true", self.slug, form_slug) ).await?; let mut answers: Vec<FormAnswer> = vec![]; for entry in data { answers.push(serde_json::from_value(entry).context("Cannot parse FormAnswer")?) } Ok(answers) } } // TODO: find a better way to have the logic implemented async fn get_auth_client_from_cache(user_cache: &mut UserCache, ha_client: &mut HelloassoClient, login_payload: LoginPayload) -> Result<AuthentifiedHelloassoClient> { async fn login(user_cache: &mut UserCache, ha_client: &mut HelloassoClient, login_payload: LoginPayload) -> Result<AuthentifiedHelloassoClient> { 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 Ok(login(user_cache, ha_client, login_payload).await?) } println!("Used anterior token"); return Ok(auth_client); }, None => { println!("First time login"); return Ok(login(user_cache, ha_client, login_payload).await?); } }; } // todo: // - make pagination working // - create paheko client // - get current paheko membership // - function to convert participants to paheko members // - clean up names and things // - map custom fields with the right thing // - handle linked users fn get_paheko_membership_from_ha_answers() { } /// for now we include the custom fields into the paheko user /// we don't have time to implement user settings to change the custom fields mapping /// for now, manual mapping #[derive(Debug, Serialize, Deserialize, Clone)] struct PahekoUser { id: Id, first_name: String, last_name: String, email: String, phone: Option<String>, address: String, city: String, postal_code: String, skills: Option<String>, job: Option<String>, birthday: Option<NaiveDate> // we will need to validate some data before } #[derive(Debug, Serialize, Deserialize, Clone)] struct PahekoMembership { id: Id, users: Vec<Id>, campaign: String, mode: MembershipMode, inception_datum: DateTime<Utc> } struct CustomFieldsMapping { helloasso_id: u64, paheko_slug: String, label: String // address: u64, // postal_code: u64, // city: u64, // phone: u64, // skills: u64, // birthday: u64, } /// rust how to access inner enum value #[derive(Debug, PartialEq, Clone, Copy)] enum HelloAssoCustomFieldType { Address, PostalCode, City, Phone, Job, Skills, Birthday, LinkedUserFirstName } impl TryFrom<&str> for HelloAssoCustomFieldType { type Error = (); fn try_from(subject: &str) -> Result<Self, Self::Error> { match subject { "Prénom conjoint" => Ok(HelloAssoCustomFieldType::LinkedUserFirstName), "ADRESSE" => Ok(HelloAssoCustomFieldType::Address), "CODE POSTAL" => Ok(HelloAssoCustomFieldType::PostalCode), "VILLE" => Ok(HelloAssoCustomFieldType::City), "PROFESSION" => Ok(HelloAssoCustomFieldType::Job), "TÉLÉPHONE" => Ok(HelloAssoCustomFieldType::Phone), "DATE DE NAISSANCE" => Ok(HelloAssoCustomFieldType::Birthday), "CENTRE D'INTÉRÊTS / COMPÉTENCES" => Ok(HelloAssoCustomFieldType::Skills), _ => Err(()) } } } fn read_custom_field(form_answer: &FormAnswer, custom_field: HelloAssoCustomFieldType) -> Option<String> { // FIXME: compute the type directly at deserialization with serde form_answer.custom_fields.iter() .find(|f| HelloAssoCustomFieldType::try_from(f.name.as_str()) == Ok(custom_field)) .and_then(|cf| Some(cf.answer.clone())) } 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()) } 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")?; let mut ha_client: HelloassoClient = Default::default(); let login_payload = LoginPayload { email: config.helloasso_email, password: config.helloasso_password }; let auth_client: AuthentifiedHelloassoClient = get_auth_client_from_cache(&mut user_cache, &mut ha_client, login_payload).await?; // dbg!(auth_client.get_user_details().await?); let slug = "l-etoile-de-bethleem-association-des-amis-de-la-chapelle-de-bethleem-d-aubevoye"; let org: Organization = auth_client.organization(slug); // dbg!(org.get_details().await?); let answers = org.get_form_answers("2023-2024".to_string()).await?; // dbg!(&answers); println!("Got {} answers to the membership form. Processing...", &answers.len()); // first, request the current list of membership in paheko that were created with helloasso // get the list of payments associated // first step: output a list of PahekoUser with PahekoMembership let pk_memberships: Vec<PahekoMembership> = vec![]; let mut pk_users: Vec<PahekoUser> = vec![]; let mut pk_memberships: Vec<PahekoMembership> = vec![]; let mut count: u64 = 0; let mut names: HashSet<String> = HashSet::new(); for answer in answers { // TODO: parse birthday // NaiveDate::parse_from_str dbg!(&answer); for custom_field in answer.custom_fields.iter() { names.insert(custom_field.name.clone()); count += 1; } let paheko_user = PahekoUser { id: generate_id(), first_name: answer.user.first_name.clone(), last_name: answer.user.last_name.clone(), email: answer.user.email.clone(), phone: parse_normalize_phone(read_custom_field(&answer, HelloAssoCustomFieldType::Phone)), skills: read_custom_field(&answer, HelloAssoCustomFieldType::Skills), address: read_custom_field(&answer, HelloAssoCustomFieldType::Address).expect("to have address"), postal_code: read_custom_field(&answer, HelloAssoCustomFieldType::PostalCode).expect("to have postal code"), city: read_custom_field(&answer, HelloAssoCustomFieldType::City).expect("to have city"), job: read_custom_field(&answer, HelloAssoCustomFieldType::Job), birthday: None }; let mut pk_membership = PahekoMembership { id: generate_id(), campaign: "".to_string(), inception_datum: Utc::now(), mode: answer.mode.clone(), users: vec![paheko_user.id.clone()] }; dbg!(&pk_membership.users); // then create optional linked user if answer.mode == MembershipMode::Couple { let mut second_pk_user = paheko_user.clone(); second_pk_user.id = generate_id(); match read_custom_field(&answer, HelloAssoCustomFieldType::LinkedUserFirstName) { Some(name) => { second_pk_user.first_name = name }, None => { second_pk_user.first_name = "Conjoint".to_string(); eprintln!("Got a user with Couple mode but no additional name given!") } } pk_membership.users.push(second_pk_user.id.clone()); pk_users.push(second_pk_user); } pk_users.push(paheko_user); pk_memberships.push(pk_membership); } dbg!(&pk_users); dbg!(&pk_memberships); dbg!(&pk_users.len()); dbg!(&pk_memberships.len()); // println!("{:?}", &pk_users.iter().map(|user| format!("{:?}", user.email)).collect::<Vec<String>>()); for u in pk_users { println!("{} {}", u.email, u.phone.unwrap_or("".to_string())); } // then, request the current list of users // match with the email address // we consider the email address as the id for a helloasso user // then, upload the PahekoMembership Ok(()) } #[tokio::main] async fn main() { let res = launch_adapter().await; dbg!(res); }