diff --git a/src/lib.rs b/src/lib.rs index 1d2162e..2233682 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,10 +1,11 @@ // std use std::{ + path::Path, borrow::Cow, collections::HashMap, - io, + io::{stdin, copy}, fmt, - fs::{read_to_string, write}, + fs::{read_to_string, write, create_dir_all, File, remove_file}, error::Error, }; @@ -15,7 +16,7 @@ use serde::Deserialize; use egg_mode::{ Token, KeyPair, - entities::UrlEntity, + entities::{UrlEntity, MediaEntity, MediaType}, tweet::{ Tweet, user_timeline, @@ -24,9 +25,17 @@ use egg_mode::{ use tokio::runtime::current_thread::block_on_all; // mammut -use mammut::{Mastodon, Data, Registration}; -use mammut::apps::{AppBuilder, Scopes}; -use mammut::status_builder::StatusBuilder; +use mammut::{ + Mastodon, + Data, + Registration, + apps::{AppBuilder, Scopes}, + status_builder::StatusBuilder, + media_builder::MediaBuilder, +}; + +// reqwest +use reqwest::blocking::Client; /********** * Generic usage functions @@ -71,6 +80,44 @@ fn get_user_timeline(config: &Config, token: Token, lid: Option) -> Result< Ok(feed.to_vec()) } +/// decode urls from UrlEntities +fn decode_urls(urls: &Vec) -> HashMap { + let mut decoded_urls = HashMap::new(); + + for url in urls { + if url.expanded_url.is_some() { + // unwrap is safe here as we just verified that there is something inside expanded_url + decoded_urls.insert(String::from(&url.url), String::from(url.expanded_url.as_deref().unwrap())); + } + } + + decoded_urls +} + +/// Retrieve a single media from a tweet and store it in a temporary file +fn get_tweet_media(m: &MediaEntity, t: &str) -> Result> { + match m.media_type { + MediaType::Photo => { + return cache_media(&m.media_url_https, t); + }, + _ => { + match &m.video_info { + Some(v) => { + for variant in &v.variants { + if variant.content_type == "video/mp4" { + return cache_media(&variant.url, t); + } + } + return Err(Box::new(ScootalooError::new(format!("Media Type for {} is video but no mp4 file URL is available", &m.url).as_str()))); + }, + None => { + return Err(Box::new(ScootalooError::new(format!("Media Type for {} is video but does not contain any video_info", &m.url).as_str()))); + }, + } + }, + }; +} + /* * Those functions are related to the Mastodon side of things */ @@ -88,7 +135,7 @@ fn get_mastodon_token(masto: &MastodonConfig) -> Mastodon { } /// build toot from tweet -fn build_status(tweet: &Tweet) -> Result> { +fn build_basic_status(tweet: &Tweet) -> Result> { let mut toot = String::from(&tweet.text); let decoded_urls = decode_urls(&tweet.entities.urls); @@ -97,26 +144,39 @@ fn build_status(tweet: &Tweet) -> Result> { toot = toot.replace(&decoded_url.0, &decoded_url.1); } - println!("{:#?}", tweet); - - Err(Box::new(ScootalooError::new("Error"))) - /* Ok(StatusBuilder::new(toot)) - */ } -/// decode urls from UrlEntities -fn decode_urls(urls: &Vec) -> HashMap { - let mut decoded_urls = HashMap::new(); - - for url in urls { - if url.expanded_url.is_some() { - // unwrap is safe here as we just verified that there is something inside expanded_url - decoded_urls.insert(String::from(&url.url), String::from(url.expanded_url.as_deref().unwrap())); - } +/* + * Generic private functions + */ +fn cache_media(u: &str, t: &str) -> Result> { + // create dir + if !Path::new(t).is_dir() { + create_dir_all(t)?; } - decoded_urls + // get file + let client = Client::new(); + let mut response = client.get(u).send()?; + + // create local file + let dest_filename = match response.url() + .path_segments() + .and_then(|segments| segments.last()) { + Some(r) => r, + None => { + return Err(Box::new(ScootalooError::new(format!("Cannot determine the destination filename for {}", u).as_str()))); + }, + }; + + let dest_filepath = format!("{}/{}", t, dest_filename); + + let mut dest_file = File::create(&dest_filepath)?; + + copy(&mut response, &mut dest_file)?; + + Ok(dest_filepath) } /********** @@ -155,6 +215,7 @@ impl std::error::Error for ScootalooError { pub struct Config { twitter: TwitterConfig, mastodon: MastodonConfig, + scootaloo: ScootalooConfig, } #[derive(Debug, Deserialize)] @@ -164,7 +225,6 @@ struct TwitterConfig { consumer_secret: String, access_key: String, access_secret: String, - last_tweet_path: String, } #[derive(Debug, Deserialize)] @@ -176,6 +236,12 @@ struct MastodonConfig { token: String, } +#[derive(Debug, Deserialize)] +struct ScootalooConfig { + last_tweet_path: String, + cache_path: String, +} + /********* * Main functions *********/ @@ -211,7 +277,7 @@ pub fn register(host: &str) { println!("Paste the returned authorization code: "); let mut input = String::new(); - io::stdin().read_line(&mut input).expect("Unable to read back registration code!"); + stdin().read_line(&mut input).expect("Unable to read back registration code!"); let code = input.trim(); let mastodon = registration.create_access_token(code.to_string()).expect("Unable to create access token!"); @@ -224,7 +290,7 @@ pub fn register(host: &str) { /// This is where the magic happens pub fn run(config: Config) { // retrieve the last tweet ID for the username - let last_tweet_id = read_state(&config.twitter.last_tweet_path); + let last_tweet_id = read_state(&config.scootaloo.last_tweet_path); // get OAuth2 token let token = get_oauth2_token(&config); @@ -247,7 +313,8 @@ pub fn run(config: Config) { feed.reverse(); for tweet in &feed { - let status = match build_status(tweet) { + // build basic status by just yielding text and dereferencing contained urls + let mut status = match build_basic_status(tweet) { Ok(t) => t, Err(e) => { println!("Could not create status from tweet {}: {}", tweet.id ,e); @@ -255,11 +322,46 @@ pub fn run(config: Config) { }, }; + // reupload the attachments if any + if let Some(m) = &tweet.extended_entities { + for media in &m.media { + let local_tweet_media_path = match get_tweet_media(&media, &config.scootaloo.cache_path) { + Ok(m) => m, + Err(e) => { + println!("Cannot get tweet media for {}: {}", &media.url, e); + continue; + }, + }; + + let mastodon_media_ids = match mastodon.media(MediaBuilder::new(Cow::from(String::from(&local_tweet_media_path)))) { + Ok(m) => { + remove_file(&local_tweet_media_path).unwrap_or_else(|e| + println!("Attachment for {} has been upload, but I’m unable to remove the existing file: {}", &local_tweet_media_path, e) + ); + m.id + }, + Err(e) => { + println!("Cannot attach media {} to Mastodon Instance: {}", &local_tweet_media_path, e); + continue; + } + }; + + // media has been successfully uploaded, adding it to the toot + match status.media_ids { + Some(ref mut i) => i.push(mastodon_media_ids), + None => status.media_ids = Some(vec![mastodon_media_ids]), + }; + + // last step, removing the reference to the media from with the toot’s text + status.status = status.status.replace(&media.url, ""); + } + } + // publish status mastodon.new_status(status).unwrap(); // write the current state (tweet ID) to avoid copying it another time - write_state(&config.twitter.last_tweet_path, tweet.id).unwrap_or_else(|e| + write_state(&config.scootaloo.last_tweet_path, tweet.id).unwrap_or_else(|e| panic!("Can’t write the last tweet retrieved: {}", e) ); }