Files
scootaloo/src/lib.rs

262 lines
7.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<u64> {
let state = read_to_string(s);
if let Ok(s) = state {
return s.parse::<u64>().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<u64>) -> Result<Vec<Tweet>, Box<dyn Error>> {
// 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<StatusBuilder, Box<dyn Error>> {
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<UrlEntity>) -> HashMap<String, String> {
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<tweet>)
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!("Cant write the last tweet retrieved: {}", e)
);
}
}