use lazy_static::lazy_static; // Create a simple module for handling the Fansly API, using reqwest to make requests to the API. // This module will contain a struct Fansly, which will have a method to get the user's profile information. use crate::structs::{ FanslyAccountResponse, FanslyBaseResponse, FanslyBaseResponseList, FanslyFollowersResponse, FanslySubscriptionsResponse, Subscription, SyncDataResponse, }; use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT}; use serde::{Deserialize, Serialize}; use serde_json::Value; use thiserror::Error; use tokio::sync::Mutex; // Create a PROGRESS mutex to hold the current sync progress, lazy initialized lazy_static! { pub static ref PROGRESS: Mutex = Mutex::new(SyncProgress::default()); } #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SyncProgress { // Should contain the current progress of the sync operation pub current_step: String, pub percentage_done: u32, pub current_count: u32, pub total_count: u32, pub complete: bool, } #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct PasteData { id: String, content: String, } #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct PasteResponse { error: Option, payload: PasteData, } #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] struct PasteRequest { content: String, } pub struct Fansly { client: reqwest::Client, token: Option, } #[derive(Debug, Error)] pub enum UploadError { #[error("HTTP error: {0}")] Http(#[from] reqwest::Error), } impl Fansly { pub fn new(token: Option) -> Self { let mut headers = HeaderMap::new(); // Set the user agent to the FanslySync/0.1.0 tanner@fanslycreatorbot.com headers.insert( USER_AGENT, HeaderValue::from_static("FanslySync/0.1.0 tanner@fanslycreatorbot.com"), // this sucks, oh well ); // If we have a token, add it to the headers\ if let Some(token) = &token { headers.insert( "Authorization", HeaderValue::from_str(&format!("{}", token)).unwrap(), ); } // Set our default base url to https://apiv3.fansly.com/api/v1/ let client = reqwest::Client::builder() .default_headers(headers) .build() .unwrap(); Self { client, token } } // Helper function to set our token on the fly pub fn set_token(&mut self, token: Option) { self.token = token; // Re-create the client with the new token (if it exists) let mut headers = HeaderMap::new(); headers.insert( USER_AGENT, HeaderValue::from_static("FanslySync/0.1.0 tanner@fanslycreatorbot.com"), ); // If we have a token, add it to the headers if let Some(token) = &self.token { headers.insert( "Authorization", HeaderValue::from_str(&format!("{}", token)).unwrap(), ); } self.client = reqwest::Client::builder() .default_headers(headers) .build() .unwrap(); } pub async fn get_profile( &self, ) -> Result, reqwest::Error> { let response = self .client .get("https://apiv3.fansly.com/api/v1/account/me") .send() .await?; if !response.status().is_success() { log::error!("[sync::process::get_profile] No successful response from API. Setting error state."); return Err(response.error_for_status().unwrap_err()); } else { log::info!("[sync::process::get_profile] Successfully fetched profile data."); } let profile = response .json::>() .await?; // Show the profile data log::info!("[sync::process::get_profile] Profile data: {:?}", profile); Ok(profile) } async fn fetch_followers( &self, account_id: &str, auth_token: &str, offset: u32, ) -> Result, reqwest::Error> { let url = format!("https://apiv3.fansly.com/api/v1/account/{}/followers?ngsw-bypass=true&limit=100&offset={}", account_id, offset); let mut headers = reqwest::header::HeaderMap::new(); headers.insert( reqwest::header::AUTHORIZATION, format!("{}", auth_token).parse().unwrap(), ); headers.insert( reqwest::header::USER_AGENT, "FanslySync/1.0.0 (tanner@fanslycreatorbot.com)" .parse() .unwrap(), ); headers.insert( reqwest::header::CONTENT_TYPE, "application/json".parse().unwrap(), ); let response = self.client.get(url).headers(headers).send().await?; if !response.status().is_success() { log::error!("[sync::process::fetch_followers] No successful response from API. Setting error state."); return Err(response.error_for_status().unwrap_err()); } let followers: FanslyBaseResponseList = response.json().await?; log::info!( "[sync::process::fetch_followers] Got {} followers from API.", followers.response.len() ); Ok(followers) } async fn fetch_subscribers( &self, auth_token: &str, offset: u32, ) -> Result, reqwest::Error> { let url = format!("https://apiv3.fansly.com/api/v1/subscribers?status=3,4&limit=100&offset={}&ngsw-bypass=true", offset); let mut headers = reqwest::header::HeaderMap::new(); headers.insert( reqwest::header::AUTHORIZATION, format!("{}", auth_token).parse().unwrap(), ); headers.insert( reqwest::header::USER_AGENT, "FanslySync/1.0.0 (tanner@fanslycreatorbot.com)" .parse() .unwrap(), ); headers.insert( reqwest::header::CONTENT_TYPE, "application/json".parse().unwrap(), ); let response = self.client.get(url).headers(headers).send().await?; if !response.status().is_success() { log::error!("[sync::process::fetch_subscribers] No successful response from API. Setting error state."); let error = response.error_for_status().unwrap_err(); return Err(error); } let subscriptions: FanslyBaseResponse = response.json().await?; log::info!( "[sync::process::fetch_subscribers] Got {} subscribers from API.", subscriptions.response.subscriptions.len() ); Ok(subscriptions.response.subscriptions) } async fn update_progress( &self, current_step: impl Into, curr_count: u32, total_count: u32, complete: bool, ) { let mut p = PROGRESS.lock().await; p.current_step = current_step.into(); p.current_count = curr_count; p.total_count = total_count; p.percentage_done = if total_count > 0 { curr_count * 100 / total_count } else { 0 }; p.complete = complete; } async fn upload_sync_data(&self, data: SyncDataResponse) -> Result { let url = "https://paste.hep.gg/api/"; // Make an JSON object with our raw data let paste_data = PasteRequest { content: serde_json::to_string(&data).unwrap(), }; let paste_data_str = serde_json::to_string(&paste_data).unwrap(); let est_upload_size = paste_data_str.len() / 1024; // in KB log::info!( "Uploading sync data to paste.hep.gg (size: {} KB)", est_upload_size ); // Create a new client and POST let response = self .client .post(url) .body(paste_data_str) .header("Content-Type", "application/json") .send() .await?; if !response.status().is_success() { let status_code = response.status(); let err = response.error_for_status_ref().unwrap_err(); let response_text = response .text() .await .unwrap_or_else(|_| "Unknown error".to_string()); log::error!( "Failed to upload sync data to paste.hep.gg. Status code: {}, Response: {}", status_code, response_text ); return Err(UploadError::Http(err)); } log::info!("Uploaded sync data successfully."); // Parse the response let paste_response: PasteResponse = response.json().await?; // Return the paste URL let paste_url = format!("https://paste.hep.gg/api/{}/raw", paste_response.payload.id); log::info!("Paste URL: {}", paste_url); Ok(paste_url) } pub async fn upload_auto_sync_data( &self, data: SyncDataResponse, token: String, ) -> Result<(), reqwest::Error> { let url = "https://botapi.fanslycreatorbot.com/sync"; // Set our content type to application/json let mut headers = reqwest::header::HeaderMap::new(); headers.insert( reqwest::header::CONTENT_TYPE, "application/json".parse().unwrap(), ); // Add our auth token to the headers headers.insert("Authorization", format!("{}", token).parse().unwrap()); let response = self .client .post(url) .headers(headers) .json(&data) .send() .await?; if !response.status().is_success() { log::error!("Failed to upload sync data..."); log::info!("Response: {:?}", response); return Err(response.error_for_status().unwrap_err()); } log::info!("Uploaded sync data successfully."); Ok(()) } pub async fn check_sync_token(&self, token: String) -> Result { // Check if the token is valid (GET /checkSyncToken with Authorization header) // If it is, return the data back from the API // If it isn't, return an error let url = "https://botapi.fanslycreatorbot.com/checkSyncToken"; // Set our content type to application/json let mut headers = reqwest::header::HeaderMap::new(); headers.insert( reqwest::header::CONTENT_TYPE, "application/json".parse().unwrap(), ); // Add our auth token to the headers headers.insert("Authorization", format!("{}", token).parse().unwrap()); let response = self.client.get(url).headers(headers).send().await; // If successful, return the data, otherwise return an error match response { Ok(response) => { if !response.status().is_success() { log::error!("Failed to check sync token..."); log::info!("Response: {:?}", response); return Err(response.error_for_status().unwrap_err()); } let json: serde_json::Value = response.json().await?; Ok(json) } Err(e) => Err(e), } } pub async fn sync(&mut self, auto: bool) -> Result { // Reset progress self.update_progress("Starting Sync".to_string(), 0, 100, false) .await; // Fetch profile log::info!("[sync::process] Fetching profile..."); let profile = self.get_profile().await.map_err(|e| e.to_string())?; if !profile.success { return Err("Failed to fetch profile".to_string()); } log::info!("[sync::process] Syncing profile..."); let account = profile.response.account; let total_followers = account.follow_count; let total_subscribers = account.subscriber_count; log::info!( "[sync::process] Account ID: {}, Followers: {}, Subscribers: {}", account.id, total_followers, total_subscribers ); let mut followers: Vec = Vec::new(); let mut subscribers: Vec = Vec::new(); log::info!("[sync::process] Fetching followers..."); // Fetch followers until we have all of them let mut offset = 0; let mut total_requests = 0; while followers.len() < total_followers as usize { log::info!( "[sync::process] Fetching followers for account {} with offset {} (total: {})", account.id, offset, total_followers ); let response = self .fetch_followers(&account.id, &self.token.as_ref().unwrap(), offset) .await .map_err(|e| e.to_string())?; log::info!( "[sync::process] Got {} followers from API.", response.response.len() ); // Collect followers for follower in response.response.clone() { followers.push(follower.follower_id); } offset += 100; total_requests += 1; // Update progress self.update_progress( "Fetching Followers".to_string(), followers.len() as u32, total_followers as u32, false, ) .await; // Every 10 requests, sleep for a bit to avoid rate limiting if total_requests % 50 == 0 { tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; } // If we've received no followers, break the loop if response.clone().response.is_empty() { log::info!("[sync::process] No more followers found, breaking the loop."); break; } } // Fetch subscribers until we have all of them offset = 0; while subscribers.len() < total_subscribers as usize { log::info!( "[sync::process] Fetching subscribers with offset {} for account {} (total: {})", offset, account.id, total_subscribers ); let response = self .fetch_subscribers(&self.token.as_ref().unwrap(), offset) .await .map_err(|e| e.to_string())?; subscribers.extend(response.clone()); offset += 100; total_requests += 1; // Update progress self.update_progress( "Fetching Subscribers".to_string(), subscribers.len() as u32, total_subscribers as u32, false, ) .await; // Every 10 requests, sleep for a bit to avoid rate limiting if total_requests % 50 == 0 { tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; } // If we've received no subscribers, break the loop if response.is_empty() { log::info!("[sync::process] No more subscribers found, breaking the loop."); break; } } log::info!( "[sync::process] Got {} followers and {} subscribers from API.", followers.len(), subscribers.len() ); log::info!("[sync::process] Sync complete."); // Reset progress self.update_progress("Sync Complete".to_string(), 100, 100, true) .await; log::info!("[sync::process] Uploading sync data to paste.hep.gg for processing..."); // Upload sync data to paste.hep.gg if !auto { let paste_url = self .upload_sync_data(SyncDataResponse { followers: followers.clone(), subscribers: subscribers.clone(), sync_data_url: "".to_string(), }) .await .map_err(|e| e.to_string())?; // Return JSON of what we fetched Ok(SyncDataResponse { followers, subscribers, sync_data_url: paste_url, }) } else { // Return JSON of what we fetched Ok(SyncDataResponse { followers, subscribers, sync_data_url: "".to_string(), }) } } }