// std use std::{ borrow::Cow, collections::HashMap, io, fmt, fs::{read_to_string, write}, error::Error, }; // toml use serde::Deserialize; // egg-mode use egg_mode::{ Token, KeyPair, entities::UrlEntity, tweet::{ Tweet, user_timeline, }, }; use tokio::runtime::current_thread::block_on_all; // mammut use mammut::{Mastodon, Data, Registration}; use mammut::apps::{AppBuilder, Scopes}; use mammut::status_builder::StatusBuilder; /********** * Generic usage functions ***********/ /* * Those functions are related to the Twitter side of things */ /// Read last tweet id from a file fn read_state(s: &str) -> Option { let state = read_to_string(s); if let Ok(s) = state { return s.parse::().ok(); } None } /// Write last treated tweet id to a file fn write_state(f: &str, s: u64) -> Result<(), std::io::Error> { write(f, format!("{}", s)) } /// Get twitter oauth2 token fn get_oauth2_token(config: &Config) -> Token { let con_token = KeyPair::new(String::from(&config.twitter.consumer_key), String::from(&config.twitter.consumer_secret)); let access_token = KeyPair::new(String::from(&config.twitter.access_key), String::from(&config.twitter.access_secret)); Token::Access { consumer: con_token, access: access_token, } } /// Get twitter user timeline fn get_user_timeline(config: &Config, token: Token, lid: Option) -> Result, Box> { // fix the page size to 200 as it is the maximum Twitter authorizes let (_timeline, feed) = block_on_all(user_timeline(&config.twitter.username, true, false, &token) .with_page_size(200) .older(lid))?; Ok(feed.to_vec()) } /* * Those functions are related to the Mastodon side of things */ /// Get Mastodon Data fn get_mastodon_token(masto: &MastodonConfig) -> Mastodon { let data = Data { base: Cow::from(String::from(&masto.base)), client_id: Cow::from(String::from(&masto.client_id)), client_secret: Cow::from(String::from(&masto.client_secret)), redirect: Cow::from(String::from(&masto.redirect)), token: Cow::from(String::from(&masto.token)), }; Mastodon::from_data(data) } /// build toot from tweet fn build_status(tweet: &Tweet) -> Result> { let mut toot = String::from(&tweet.text); let decoded_urls = decode_urls(&tweet.entities.urls); for decoded_url in decoded_urls { toot = toot.replace(&decoded_url.0, &decoded_url.1); } Ok(StatusBuilder::new(toot)) } 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 } /********** * local error handler **********/ #[derive(Debug)] struct ScootalooError { details: String, } impl ScootalooError { fn new(msg: &str) -> ScootalooError { ScootalooError { details: String::from(msg), } } } impl fmt::Display for ScootalooError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}", self.details) } } impl std::error::Error for ScootalooError { fn description(&self) -> &str { &self.details } } /********** * Config structure ***********/ /// General configuration Struct #[derive(Debug, Deserialize)] pub struct Config { twitter: TwitterConfig, mastodon: MastodonConfig, } #[derive(Debug, Deserialize)] struct TwitterConfig { username: String, consumer_key: String, consumer_secret: String, access_key: String, access_secret: String, last_tweet_path: String, } #[derive(Debug, Deserialize)] struct MastodonConfig { base: String, client_id: String, client_secret: String, redirect: String, token: String, } /********* * Main functions *********/ /// Parses the TOML file into a Config Struct pub fn parse_toml(toml_file: &str) -> Config { let toml_config = read_to_string(toml_file).unwrap_or_else(|e| panic!("Cannot open config file {}: {}", toml_file, e) ); let config: Config = toml::from_str(&toml_config).unwrap_or_else(|e| panic!("Cannot parse TOML file {}: {}", toml_file, e) ); config } /// Generic register function /// As this function is supposed to be run only once, it will panic for every error it encounters /// Most of this function is a direct copy/paste of the official `mammut` crate pub fn register(host: &str) { let app = AppBuilder { client_name: env!("CARGO_PKG_NAME"), redirect_uris: "urn:ietf:wg:oauth:2.0:oob", scopes: Scopes::Write, website: Some("https://framagit.org/veretcle/scootaloo"), }; let mut registration = Registration::new(host); registration.register(app).expect("Registration failed!"); let url = registration.authorise().expect("Cannot generate registration URI!"); println!("Click this link to authorize on Mastodon: {}", url); println!("Paste the returned authorization code: "); let mut input = String::new(); io::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!"); let toml = toml::to_string(&*mastodon).unwrap(); println!("Please insert the following block at the end of your configuration file:\n[mastodon]\n{}", toml); } /// 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); // get OAuth2 token let token = get_oauth2_token(&config); // get Mastodon instance let mastodon = get_mastodon_token(&config.mastodon); // get user timeline feed (Vec) let mut feed = get_user_timeline(&config, token, last_tweet_id).unwrap_or_else(|e| panic!("Something went wrong when trying to retrieve {}’s timeline: {}", &config.twitter.username, e) ); // empty feed -> exiting if feed.is_empty() { println!("Nothing to retrieve since last time, exiting…"); return; } // order needs to be chronological feed.reverse(); for tweet in &feed { let status = match build_status(tweet) { Ok(t) => t, Err(e) => { println!("Could not create status from tweet {}: {}", tweet.id ,e); continue; }, }; // 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| panic!("Can’t write the last tweet retrieved: {}", e) ); } }