mirror of
https://framagit.org/veretcle/scootaloo.git
synced 2025-07-20 17:11:19 +02:00
262 lines
7.0 KiB
Rust
262 lines
7.0 KiB
Rust
// 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!("Can’t write the last tweet retrieved: {}", e)
|
||
);
|
||
}
|
||
}
|
||
|