diff --git a/Cargo.lock b/Cargo.lock index cc66139..b1b767f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1342,8 +1342,9 @@ dependencies = [ [[package]] name = "scootaloo" -version = "1.0.0" +version = "1.1.0" dependencies = [ + "base64", "clap", "egg-mode", "futures", diff --git a/Cargo.toml b/Cargo.toml index f8aac6b..cc4e955 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,13 @@ [package] name = "scootaloo" -version = "1.0.0" +version = "1.1.0" authors = ["VC "] edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +base64 = "^0.13" regex = "^1" serde = { version = "1.0", features = ["derive"] } toml = "^0.5" diff --git a/src/lib.rs b/src/lib.rs index 75f6abe..361b457 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,7 +13,7 @@ mod twitter; use twitter::*; mod util; -use crate::util::generate_media_ids; +use util::{base64_media, generate_media_ids}; mod state; pub use state::{init_db, migrate_db}; @@ -23,7 +23,9 @@ use futures::StreamExt; use html_escape::decode_html_entities; use isolang::Language; use log::info; -use megalodon::{megalodon::PostStatusInputOptions, Megalodon}; +use megalodon::{ + megalodon::PostStatusInputOptions, megalodon::UpdateCredentialsInputOptions, Megalodon, +}; use regex::Regex; use rusqlite::Connection; use std::sync::Arc; @@ -242,3 +244,62 @@ pub async fn run(config: Config) { } } } + +/// Copies the Twitter profile into Mastodon +#[tokio::main] +pub async fn profile(config: Config, bot: Option) { + let mut stream = futures::stream::iter(config.mastodon.into_values()) + .map(|mastodon_config| { + let token = get_oauth2_token(&config.twitter); + + spawn(async move { + // get the user of the last tweet of the feed + let twitter_user = + get_user_timeline(&mastodon_config.twitter_screen_name, &token, None, 1) + .await? + .first() + .ok_or_else(|| ScootalooError::new("Can’t extract a tweet from the feed!"))? + .clone() + .user + .ok_or_else(|| ScootalooError::new("No user in Tweet!"))?; + + let mut display_name = twitter_user.name.clone(); + display_name.truncate(30); + + let header = match twitter_user.profile_banner_url { + Some(h) => Some(base64_media(&h).await?), + None => None, + }; + + let update_creds = UpdateCredentialsInputOptions { + discoverable: None, + bot, + display_name: Some(display_name), + note: twitter_user.description, + avatar: Some( + base64_media(&twitter_user.profile_image_url_https.replace("_normal", "")) + .await?, + ), + header, + locked: None, + source: None, + fields_attributes: None, + }; + + let mastodon = get_mastodon_token(&mastodon_config); + + mastodon.update_credentials(Some(&update_creds)).await?; + + Ok::<(), ScootalooError>(()) + }) + }) + .buffer_unordered(config.scootaloo.rate_limit.unwrap_or(DEFAULT_RATE_LIMIT)); + + while let Some(result) = stream.next().await { + match result { + Ok(Err(e)) => eprintln!("Error within thread: {}", e), + Err(e) => eprintln!("Error with thread: {}", e), + _ => (), + } + } +} diff --git a/src/main.rs b/src/main.rs index 43ca86d..bc57139 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,4 @@ -use clap::{Arg, Command}; +use clap::{Arg, ArgAction, Command}; use log::LevelFilter; use scootaloo::*; use simple_logger::SimpleLogger; @@ -87,12 +87,43 @@ fn main() { .display_order(1), ) .arg( - Arg::new("name") - .short('n') - .long("name") - .help("Twitter Screen Name (like https://twitter.com/screen_name, no default)") - .num_args(1) - .display_order(2) + Arg::new("name") + .short('n') + .long("name") + .help("Twitter Screen Name (like https://twitter.com/screen_name, no default)") + .num_args(1) + .display_order(2) + ) + ) + .subcommand( + Command::new("copyprofile") + .version(env!("CARGO_PKG_VERSION")) + .about("Command to copy a Twitter profile into Mastodon") + .arg( + Arg::new("config") + .short('c') + .long("config") + .value_name("CONFIG_FILE") + .help(&format!("TOML config file for scootaloo (default {})", DEFAULT_CONFIG_PATH)) + .default_value(DEFAULT_CONFIG_PATH) + .num_args(1) + .display_order(1), + ) + .arg( + Arg::new("name") + .short('n') + .long("name") + .help("Mastodon Config name (as seen in the config file)") + .num_args(1) + .display_order(2) + ) + .arg( + Arg::new("bot") + .short('b') + .long("bot") + .help("Declare user as bot") + .action(ArgAction::SetTrue) + .display_order(3) ) ) .get_matches(); @@ -123,6 +154,22 @@ fn main() { .unwrap(); return; } + Some(("copyprofile", sub_m)) => { + let mut config = parse_toml(sub_m.get_one::("config").unwrap()); + // filters out the user passed in cli from the global configuration + if let Some(m) = sub_m.get_one::("name") { + match config.mastodon.get(m) { + Some(_) => { + config.mastodon.retain(|k, _| k == m); + } + None => panic!("Config file does not contain conf for {}", &m), + } + } + + profile(config, sub_m.get_flag("bot").then_some(true)); + + return; + } _ => (), } diff --git a/src/util.rs b/src/util.rs index b005279..6d34d78 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,5 +1,6 @@ use crate::{twitter::get_tweet_media, ScootalooError}; +use base64::encode; use egg_mode::tweet::Tweet; use futures::{stream, stream::StreamExt}; use log::{error, info, warn}; @@ -71,6 +72,27 @@ pub async fn generate_media_ids( (media_url, media_ids) } +/// Transforms the media into a base64 equivalent +pub async fn base64_media(u: &str) -> Result> { + let mut response = reqwest::get(u).await?; + + let mut buffer = Vec::new(); + + while let Some(chunk) = response.chunk().await? { + copy(&mut &*chunk, &mut buffer).await?; + } + + let content_type = response + .headers() + .get("content-type") + .ok_or_else(|| ScootalooError::new(&format!("Cannot get media content type for {}", u)))? + .to_str()?; + + let encoded_f = encode(buffer); + + Ok(format!("data:{};base64,{}", content_type, encoded_f)) +} + /// Gets and caches Twitter Media inside the determined temp dir pub async fn cache_media(u: &str, t: &str) -> Result> { // create dir @@ -129,4 +151,17 @@ mod tests { remove_dir_all(TMP_DIR).unwrap(); } + + #[tokio::test] + async fn test_base64_media() { + let img = base64_media( + "https://forum.nintendojo.fr/styles/prosilver/theme/images/ndfr_casual.png", + ) + .await + .unwrap(); + + assert!(img.starts_with("data:image/png;base64,")); + + assert!(img.ends_with("=")); + } }