25 Commits

Author SHA1 Message Date
VC
01bac63fb9 Merge branch 'reply_improvements' into 'master'
refactor: improve reply/thread management

See merge request veretcle/scootaloo!25
2022-11-09 20:26:20 +00:00
VC
4f5663b450 feature: better error implementation for ScootalooError inside async block 2022-11-09 19:36:00 +01:00
VC
9a9c4b4809 chore: cargo update 2022-11-09 18:33:04 +01:00
VC
9970968b47 refactor: avoid panicking into thread, bubble up errors to main thread to be handled 2022-11-09 18:23:06 +01:00
VC
291c86677e refactor: get mastodon token after ensuring feed is not empty 2022-11-09 08:40:04 +01:00
VC
31afb1cf7d Merge branch 'async_media_upload' into 'master'
Async media upload

See merge request veretcle/scootaloo!24
2022-11-08 13:35:06 +00:00
VC
4415c4ac12 refactor: better logic flow for uploading/deleting media 2022-11-08 10:54:42 +01:00
VC
89f1372f9f bump: version v0.8.0 2022-11-08 08:54:36 +01:00
VC
06904434c8 fix: indentation error when registering 2022-11-08 08:54:36 +01:00
VC
3c64df23bc refactor: add info/debug 2022-11-08 08:54:32 +01:00
VC
c62f67c3b3 refactor: simpler mtask var 2022-11-08 08:37:26 +01:00
VC
3b0e7234af refactor: downloads/uploads every media from a tweet async way 2022-11-08 08:37:17 +01:00
VC
62011b4b81 refactor: downloads/uploads every media from a tweet async way 2022-11-07 21:47:12 +01:00
VC
5ce3bde3e7 fix: remove unecessary \n in TOML conf 2022-11-07 18:25:55 +01:00
VC
ab4184c0ed Merge branch 'async_multi_account' into 'master'
feat: attempt for async treatment of all accounts

See merge request veretcle/scootaloo!23
2022-11-05 09:36:55 +00:00
VC
de758c7bda refactor: separate function for media ids 2022-11-05 10:23:21 +01:00
VC
df75520175 feat: async treatment of all accounts 2022-11-04 15:26:27 +01:00
VC
73244f9ecc Merge branch 'multi_account_scootaloo' into 'master'
Multi account scootaloo

See merge request veretcle/scootaloo!22
2022-11-03 22:38:26 +00:00
VC
dad49da090 feat: add multi-account ability 2022-11-03 23:30:50 +01:00
VC
44ec3edfe2 Merge branch 'rust_1_63' into 'master'
feat: adapt to rust 1.63

See merge request veretcle/scootaloo!21
2022-08-17 16:06:39 +00:00
VC
8673dd7866 feat: adapt to rust 1.63 2022-08-17 18:02:12 +02:00
VC
ff496b167d Merge branch 'fmt_clippy' into 'master'
style: fmt & clippy processed

See merge request veretcle/scootaloo!20
2022-08-11 13:29:22 +00:00
VC
97ab6f4925 feat: bump version 2022-08-11 15:26:36 +02:00
VC
5b512cb757 ci: common ci 2022-08-11 13:50:52 +02:00
VC
b11595bfca style: fmt & clippy processed 2022-08-11 12:33:05 +02:00
14 changed files with 1487 additions and 832 deletions

View File

@@ -1,15 +1,5 @@
--- ---
include:
stages: project: 'veretcle/ci-common'
- build ref: 'main'
file: 'ci_rust.yml'
rust-latest:
stage: build
artifacts:
paths:
- target/release/scootaloo
image: rust:latest
script:
- cargo test
- cargo build --release --verbose
- strip target/release/${CI_PROJECT_NAME}

1204
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "scootaloo" name = "scootaloo"
version = "0.5.2" version = "0.8.1"
authors = ["VC <veretcle+framagit@mateu.be>"] authors = ["VC <veretcle+framagit@mateu.be>"]
edition = "2021" edition = "2021"
@@ -20,4 +20,7 @@ html-escape = "^0.2"
reqwest = "^0.11" reqwest = "^0.11"
log = "^0.4" log = "^0.4"
simple_logger = "^2.1" simple_logger = "^2.1"
mime = "^0.3"
[profile.release]
strip = true

View File

@@ -10,23 +10,23 @@ If any of the last steps failed, the Toot gets published with the exact same tex
RT are excluded, replies are included when considered part of a thread (reply to self), not the actual replies to other Twitter users. RT are excluded, replies are included when considered part of a thread (reply to self), not the actual replies to other Twitter users.
# Usage # Usage
## Configuring
First up, create a configuration file (default path is `/usr/local/etc/scootaloo.toml`). It will look like this: First up, create a configuration file (default path is `/usr/local/etc/scootaloo.toml`). It will look like this:
```toml ```toml
[scootaloo] [scootaloo]
db_path = "/var/lib/scootaloo/scootaloo.sqlite" ## file containing the SQLite Tweet corresponding Toot DB, must be writeable
db_path="/var/lib/scootaloo/scootaloo.sqlite" ## file containing the SQLite Tweet corresponding Toot DB, must be writeable cache_path = "/tmp/scootaloo" ## a dir where the temporary files will be download, must be writeable
cache_path="/tmp/scootaloo" ## a dir where the temporary files will be download, must be writeable
[twitter] [twitter]
username="NintendojoFR" ## User Timeline to copy
## Consumer/Access key for Twitter (can be generated at https://developer.twitter.com/en/apps) ## Consumer/Access key for Twitter (can be generated at https://developer.twitter.com/en/apps)
consumer_key="MYCONSUMERKEY" consumer_key = "MYCONSUMERKEY"
consumer_secret="MYCONSUMERSECRET" consumer_secret = "MYCONSUMERSECRET"
access_key="MYACCESSKEY" access_key = "MYACCESSKEY"
access_secret="MYACCESSSECRET" access_secret = "MYACCESSSECRET"
[mastodon]
``` ```
Then run the command with the `init` subcommand to initiate the DB: Then run the command with the `init` subcommand to initiate the DB:
@@ -44,7 +44,8 @@ scootaloo register --host https://m.nintendojo.fr
This will give you the end of the TOML file. It will look like this: This will give you the end of the TOML file. It will look like this:
```toml ```toml
[mastodon] [mastodon.nintendojofr] ## account
twitter_screen_name="NintendojoFR" ## User Timeline to copy
base = "https://m.nintendojo.fr" base = "https://m.nintendojo.fr"
client_id = "MYCLIENTID" client_id = "MYCLIENTID"
client_secret = "MYCLIENTSECRET" client_secret = "MYCLIENTSECRET"
@@ -52,6 +53,10 @@ redirect = "urn:ietf:wg:oauth:2.0:oob"
token = "MYTOKEN" token = "MYTOKEN"
``` ```
You can add other account if you like, after the `[mastodon]` moniker. Scootaloo would theorically support an unlimited number of accounts.
## Running
You can then run the application via `cron` for example. Here is the generic usage: You can then run the application via `cron` for example. Here is the generic usage:
```sh ```sh
@@ -71,6 +76,7 @@ OPTIONS:
SUBCOMMANDS: SUBCOMMANDS:
help Prints this message or the help of the given subcommand(s) help Prints this message or the help of the given subcommand(s)
init Command to init Scootaloo DB init Command to init Scootaloo DB
migrate Command to migrate Scootaloo DB
register Command to register to a Mastodon Instance register Command to register to a Mastodon Instance
``` ```
@@ -86,5 +92,17 @@ sqlite3 /var/lib/scootaloo/scootaloo.sqlite
And inserting the data: And inserting the data:
```sql ```sql
INSERT INTO tweet_to_toot VALUES (1383782580412030982, ""); INSERT INTO tweet_to_toot VALUES ("<twitter_screen_name>", 1383782580412030982, "<twitter_screen_name>");
``` ```
The last value is supposed to be the Toot ID. It cannot be null, so you better initialize it with something unique, like the Twitter Screen Name for example.
# Migrating from Scootaloo ⩽ 0.6.1
The DB scheme has change between version 0.6.x and 0.7.x (this is due to the multi-account nature of Scootaloo from 0.7.x onward). You need to migrate your DB. You can do so by issuing the command:
```
scootaloo migrate
```
You can optionnally specify a screen name with the `--name` option. By default, itll take the first screen name in the config file.

View File

@@ -1,17 +1,17 @@
use std::fs::read_to_string; use std::{collections::HashMap, fs::read_to_string};
use serde::Deserialize; use serde::Deserialize;
/// General configuration Struct /// General configuration Struct
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct Config { pub struct Config {
pub twitter: TwitterConfig, pub twitter: TwitterConfig,
pub mastodon: MastodonConfig, pub mastodon: HashMap<String, MastodonConfig>,
pub scootaloo: ScootalooConfig, pub scootaloo: ScootalooConfig,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct TwitterConfig { pub struct TwitterConfig {
pub username: String,
pub consumer_key: String, pub consumer_key: String,
pub consumer_secret: String, pub consumer_secret: String,
pub access_key: String, pub access_key: String,
@@ -20,6 +20,7 @@ pub struct TwitterConfig {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct MastodonConfig { pub struct MastodonConfig {
pub twitter_screen_name: String,
pub base: String, pub base: String,
pub client_id: String, pub client_id: String,
pub client_secret: String, pub client_secret: String,
@@ -35,14 +36,11 @@ pub struct ScootalooConfig {
/// Parses the TOML file into a Config Struct /// Parses the TOML file into a Config Struct
pub fn parse_toml(toml_file: &str) -> Config { pub fn parse_toml(toml_file: &str) -> Config {
let toml_config = read_to_string(toml_file).unwrap_or_else(|e| let toml_config = read_to_string(toml_file)
panic!("Cannot open config file {}: {}", toml_file, e) .unwrap_or_else(|e| panic!("Cannot open config file {}: {}", toml_file, e));
);
let config: Config = toml::from_str(&toml_config).unwrap_or_else(|e| let config: Config = toml::from_str(&toml_config)
panic!("Cannot parse TOML file {}: {}", toml_file, e) .unwrap_or_else(|e| panic!("Cannot parse TOML file {}: {}", toml_file, e));
);
config config
} }

View File

@@ -1,8 +1,12 @@
use std::{ use std::{
fmt::{Display, Formatter, Result}, boxed::Box,
convert::From,
error::Error, error::Error,
fmt::{Display, Formatter, Result},
}; };
use elefren::Error as elefrenError;
#[derive(Debug)] #[derive(Debug)]
pub struct ScootalooError { pub struct ScootalooError {
details: String, details: String,
@@ -24,3 +28,14 @@ impl Display for ScootalooError {
} }
} }
impl From<Box<dyn Error>> for ScootalooError {
fn from(error: Box<dyn Error>) -> Self {
ScootalooError::new(&format!("Error in a subset crate: {}", error))
}
}
impl From<elefrenError> for ScootalooError {
fn from(error: elefrenError) -> Self {
ScootalooError::new(&format!("Error in elefren crate: {}", error))
}
}

View File

@@ -2,152 +2,153 @@ mod error;
use error::ScootalooError; use error::ScootalooError;
mod config; mod config;
use config::Config;
pub use config::parse_toml; pub use config::parse_toml;
use config::Config;
mod mastodon; mod mastodon;
use mastodon::{get_mastodon_token, build_basic_status};
pub use mastodon::register; pub use mastodon::register;
use mastodon::{build_basic_status, get_mastodon_token};
mod twitter; mod twitter;
use twitter::*; use twitter::*;
mod util; mod util;
use crate::util::generate_media_ids;
mod state; mod state;
pub use state::{init_db, migrate_db};
use state::{read_state, write_state, TweetToToot}; use state::{read_state, write_state, TweetToToot};
pub use state::init_db;
use std::borrow::Cow; use elefren::{prelude::*, status_builder::StatusBuilder};
use tokio::fs::remove_file; use log::info;
use elefren::{
prelude::*,
status_builder::StatusBuilder,
};
use log::{info, warn, error, debug};
use rusqlite::Connection; use rusqlite::Connection;
use std::sync::Arc;
use tokio::{spawn, sync::Mutex};
/// This is where the magic happens /// This is where the magic happens
#[tokio::main] #[tokio::main]
pub async fn run(config: Config) { pub async fn run(config: Config) {
// create the task vector for handling multiple accounts
let mut mtask = vec![];
// open the SQLite connection // open the SQLite connection
let conn = Connection::open(&config.scootaloo.db_path) let conn = Arc::new(Mutex::new(
.unwrap_or_else(|e| Connection::open(&config.scootaloo.db_path).unwrap_or_else(|e| {
panic!("Something went wrong when opening the DB {}: {}", &config.scootaloo.db_path, e) panic!(
); "Something went wrong when opening the DB {}: {}",
// retrieve the last tweet ID for the username &config.scootaloo.db_path, e
let last_tweet_id = read_state(&conn, None) )
.unwrap_or_else(|e| }),
panic!("Cannot retrieve last_tweet_id: {}", e) ));
)
.map(|s| s.tweet_id);
// get OAuth2 token for mastodon_config in config.mastodon.into_values() {
let token = get_oauth2_token(&config.twitter); // create temporary value for each task
let scootaloo_cache_path = config.scootaloo.cache_path.clone();
let token = get_oauth2_token(&config.twitter);
let task_conn = conn.clone();
// get Mastodon instance let task = spawn(async move {
let mastodon = get_mastodon_token(&config.mastodon); info!("Starting treating {}", &mastodon_config.twitter_screen_name);
// get user timeline feed (Vec<tweet>) // retrieve the last tweet ID for the username
let mut feed = get_user_timeline(&config.twitter, token, last_tweet_id) let lconn = task_conn.lock().await;
.await let last_tweet_id =
.unwrap_or_else(|e| read_state(&lconn, &mastodon_config.twitter_screen_name, None)?.map(|r| r.tweet_id);
panic!("Something went wrong when trying to retrieve {}s timeline: {}", &config.twitter.username, e) drop(lconn);
);
// empty feed -> exiting // get user timeline feed (Vec<tweet>)
if feed.is_empty() { let mut feed =
info!("Nothing to retrieve since last time, exiting…"); get_user_timeline(&mastodon_config.twitter_screen_name, &token, last_tweet_id)
return; .await?;
}
// order needs to be chronological // empty feed -> exiting
feed.reverse(); if feed.is_empty() {
info!("Nothing to retrieve since last time, exiting…");
return Ok(());
}
for tweet in &feed { // get Mastodon instance
debug!("Treating Tweet {} inside feed", tweet.id); let mastodon = get_mastodon_token(&mastodon_config);
// initiate the toot_reply_id var
let mut toot_reply_id: Option<String> = None;
// determine if the tweet is part of a thread (response to self) or a standard response
if let Some(r) = &tweet.in_reply_to_screen_name {
if &r.to_lowercase() != &config.twitter.username.to_lowercase() {
// we are responding not threading
info!("Tweet is a direct response, skipping");
continue;
}
info!("Tweet is a thread");
toot_reply_id = read_state(&conn, tweet.in_reply_to_status_id)
.unwrap_or(None)
.map(|s| s.toot_id);
};
// build basic status by just yielding text and dereferencing contained urls // order needs to be chronological
let mut status_text = build_basic_status(tweet); feed.reverse();
let mut status_medias: Vec<String> = vec![]; for tweet in &feed {
// reupload the attachments if any info!("Treating Tweet {} inside feed", tweet.id);
if let Some(m) = &tweet.extended_entities { // initiate the toot_reply_id var
for media in &m.media { let mut toot_reply_id: Option<String> = None;
let local_tweet_media_path = match get_tweet_media(&media, &config.scootaloo.cache_path).await { // determine if the tweet is part of a thread (response to self) or a standard response
Ok(m) => m, if let Some(r) = &tweet.in_reply_to_screen_name {
Err(e) => { if r.to_lowercase() != mastodon_config.twitter_screen_name.to_lowercase() {
error!("Cannot get tweet media for {}: {}", &media.url, e); // we are responding not threading
continue; info!("Tweet is a direct response, skipping");
},
};
let mastodon_media_ids = match mastodon.media(Cow::from(local_tweet_media_path.to_owned())) {
Ok(m) => {
remove_file(&local_tweet_media_path)
.await
.unwrap_or_else(|e|
warn!("Attachment for {} has been uploaded, but Im unable to remove the existing file: {}", &local_tweet_media_path, e)
);
m.id
},
Err(e) => {
error!("Attachment {} cannot be uploaded to Mastodon Instance: {}", &local_tweet_media_path, e);
continue; continue;
} }
info!("Tweet is a thread");
// get the corresponding toot id
let lconn = task_conn.lock().await;
toot_reply_id = read_state(
&lconn,
&mastodon_config.twitter_screen_name,
tweet.in_reply_to_status_id,
)
.unwrap_or(None)
.map(|s| s.toot_id);
drop(lconn);
}; };
status_medias.push(mastodon_media_ids); // build basic status by just yielding text and dereferencing contained urls
let mut status_text = build_basic_status(tweet);
// last step, removing the reference to the media from with the toots text // building associative media list
status_text = status_text.replace(&media.url, ""); let (media_url, status_medias) =
generate_media_ids(tweet, &scootaloo_cache_path, &mastodon).await;
status_text = status_text.replace(&media_url, "");
info!("Building corresponding Mastodon status");
let mut status_builder = StatusBuilder::new();
status_builder.status(&status_text).media_ids(status_medias);
if let Some(i) = toot_reply_id {
status_builder.in_reply_to(&i);
}
// can be activated for test purposes
// status_builder.visibility(elefren::status_builder::Visibility::Private);
let status = status_builder.build()?;
let published_status = mastodon.new_status(status)?;
// this will return if it cannot publish the status preventing the last_tweet from
// being written into db
let ttt_towrite = TweetToToot {
twitter_screen_name: mastodon_config.twitter_screen_name.clone(),
tweet_id: tweet.id,
toot_id: published_status.id,
};
// write the current state (tweet ID and toot ID) to avoid copying it another time
let lconn = task_conn.lock().await;
write_state(&lconn, ttt_towrite)?;
drop(lconn);
} }
Ok::<(), ScootalooError>(())
});
// push each task into the vec task
mtask.push(task);
}
// launch and wait for every handle
for handle in mtask {
match handle.await {
Ok(Err(e)) => eprintln!("Error within thread: {}", e),
Err(e) => eprintln!("Error with thread: {}", e),
_ => (),
} }
// finished reuploading attachments, now lets do the toot baby!
debug!("Building corresponding Mastodon status");
let mut status_builder = StatusBuilder::new();
status_builder.status(&status_text)
.media_ids(status_medias);
if let Some(i) = toot_reply_id {
status_builder.in_reply_to(&i);
}
let status = status_builder.build()
.expect(&format!("Cannot build status with text {}", &status_text));
// publish status
// again unwrap is safe here as we are in the main thread
let published_status = mastodon.new_status(status).unwrap();
// this will panic if it cannot publish the status, which is a good thing, it allows the
// last_tweet gathered not to be written
let ttt_towrite = TweetToToot {
tweet_id: tweet.id,
toot_id: published_status.id,
};
// write the current state (tweet ID and toot ID) to avoid copying it another time
write_state(&conn, ttt_towrite).unwrap_or_else(|e|
panic!("Cant write the last tweet retrieved: {}", e)
);
} }
} }

View File

@@ -1,70 +1,136 @@
use scootaloo::*;
use clap::{App, Arg, SubCommand}; use clap::{App, Arg, SubCommand};
use log::{LevelFilter, error}; use log::{error, LevelFilter};
use scootaloo::*;
use simple_logger::SimpleLogger; use simple_logger::SimpleLogger;
use std::str::FromStr; use std::str::FromStr;
const DEFAULT_CONFIG_PATH: &'static str = "/usr/local/etc/scootaloo.toml"; const DEFAULT_CONFIG_PATH: &str = "/usr/local/etc/scootaloo.toml";
fn main() { fn main() {
let matches = App::new(env!("CARGO_PKG_NAME")) let matches = App::new(env!("CARGO_PKG_NAME"))
.version(env!("CARGO_PKG_VERSION")) .version(env!("CARGO_PKG_VERSION"))
.about("A Twitter to Mastodon bot") .about("A Twitter to Mastodon bot")
.arg(Arg::with_name("config") .arg(
.short("c") Arg::with_name("config")
.long("config") .short("c")
.value_name("CONFIG_FILE") .long("config")
.help(&format!("TOML config file for scootaloo (default {})", DEFAULT_CONFIG_PATH)) .value_name("CONFIG_FILE")
.takes_value(true) .help(&format!(
.display_order(1)) "TOML config file for scootaloo (default {})",
.arg(Arg::with_name("log_level") DEFAULT_CONFIG_PATH
.short("l") ))
.long("loglevel") .takes_value(true)
.value_name("LOGLEVEL") .display_order(1),
.help("Log level.Valid values are: Off, Warn, Error, Info, Debug") )
.takes_value(true) .arg(
.display_order(2)) Arg::with_name("log_level")
.subcommand(SubCommand::with_name("register") .short("l")
.version(env!("CARGO_PKG_VERSION")) .long("loglevel")
.about("Command to register to a Mastodon Instance") .value_name("LOGLEVEL")
.arg(Arg::with_name("host") .help("Log level.Valid values are: Off, Warn, Error, Info, Debug")
.short("H") .takes_value(true)
.long("host") .display_order(2),
.value_name("HOST") )
.help("Base URL of the Mastodon instance to register to (no default)") .subcommand(
.takes_value(true) SubCommand::with_name("register")
.required(true) .version(env!("CARGO_PKG_VERSION"))
.display_order(1))) .about("Command to register to a Mastodon Instance")
.subcommand(SubCommand::with_name("init") .arg(
.version(env!("CARGO_PKG_VERSION")) Arg::with_name("host")
.about("Command to init Scootaloo DB") .short("H")
.arg(Arg::with_name("config") .long("host")
.short("c") .value_name("HOST")
.long("config") .help("Base URL of the Mastodon instance to register to (no default)")
.value_name("CONFIG_FILE") .takes_value(true)
.help(&format!("TOML config file for scootaloo (default {})", DEFAULT_CONFIG_PATH)) .required(true)
.takes_value(true) .display_order(1)
.display_order(1))) )
.get_matches(); .arg(
Arg::with_name("name")
.short("n")
.long("name")
.help("Twitter Screen Name (like https://twitter.com/screen_name, no default)")
.takes_value(true)
.required(true)
.display_order(2)
),
)
.subcommand(
SubCommand::with_name("init")
.version(env!("CARGO_PKG_VERSION"))
.about("Command to init Scootaloo DB")
.arg(
Arg::with_name("config")
.short("c")
.long("config")
.value_name("CONFIG_FILE")
.help(&format!(
"TOML config file for scootaloo (default {})",
DEFAULT_CONFIG_PATH
))
.takes_value(true)
.display_order(1),
),
)
.subcommand(
SubCommand::with_name("migrate")
.version(env!("CARGO_PKG_VERSION"))
.about("Command to migrate Scootaloo DB")
.arg(
Arg::with_name("config")
.short("c")
.long("config")
.value_name("CONFIG_FILE")
.help(&format!("TOML config file for scootaloo (default {})", DEFAULT_CONFIG_PATH))
.takes_value(true)
.display_order(1),
)
.arg(
Arg::with_name("name")
.short("n")
.long("name")
.help("Twitter Screen Name (like https://twitter.com/screen_name, no default)")
.takes_value(true)
.display_order(2)
)
)
.get_matches();
match matches.subcommand() { match matches.subcommand() {
("register", Some(sub_m)) => { ("register", Some(sub_m)) => {
register(sub_m.value_of("host").unwrap()); register(
sub_m.value_of("host").unwrap(),
sub_m.value_of("name").unwrap(),
);
return; return;
}, }
("init", Some(sub_m)) => { ("init", Some(sub_m)) => {
let config = parse_toml(sub_m.value_of("config").unwrap_or(DEFAULT_CONFIG_PATH)); let config = parse_toml(sub_m.value_of("config").unwrap_or(DEFAULT_CONFIG_PATH));
init_db(&config.scootaloo.db_path).unwrap(); init_db(&config.scootaloo.db_path).unwrap();
return; return;
}, }
("migrate", Some(sub_m)) => {
let config = parse_toml(sub_m.value_of("config").unwrap_or(DEFAULT_CONFIG_PATH));
let config_twitter_screen_name =
&config.mastodon.values().next().unwrap().twitter_screen_name;
migrate_db(
&config.scootaloo.db_path,
sub_m.value_of("name").unwrap_or(config_twitter_screen_name),
)
.unwrap();
return;
}
_ => (), _ => (),
} }
if matches.is_present("log_level") { if matches.is_present("log_level") {
match LevelFilter::from_str(matches.value_of("log_level").unwrap()) { match LevelFilter::from_str(matches.value_of("log_level").unwrap()) {
Ok(level) => { SimpleLogger::new().with_level(level).init().unwrap()}, Ok(level) => SimpleLogger::new().with_level(level).init().unwrap(),
Err(e) => { Err(e) => {
SimpleLogger::new().with_level(LevelFilter::Error).init().unwrap(); SimpleLogger::new()
.with_level(LevelFilter::Error)
.init()
.unwrap();
error!("Unknown log level filter: {}", e); error!("Unknown log level filter: {}", e);
} }
}; };
@@ -74,4 +140,3 @@ fn main() {
run(config); run(config);
} }

View File

@@ -1,36 +1,37 @@
use crate::config::MastodonConfig; use crate::config::MastodonConfig;
use std::{
borrow::Cow,
collections::HashMap,
io::stdin,
};
use html_escape::decode_html_entities;
use egg_mode::{ use egg_mode::{
entities::{MentionEntity, UrlEntity},
tweet::Tweet, tweet::Tweet,
entities::{UrlEntity, MentionEntity},
};
use elefren::{
prelude::*,
apps::App,
scopes::Scopes,
}; };
use elefren::{apps::App, prelude::*, scopes::Scopes};
use html_escape::decode_html_entities;
use std::{borrow::Cow, collections::HashMap, io::stdin};
/// Decodes the Twitter mention to something that will make sense once Twitter has joined the /// Decodes the Twitter mention to something that will make sense once Twitter has joined the
/// Fediverse /// Fediverse
fn twitter_mentions(ums: &Vec<MentionEntity>) -> HashMap<String, String> { fn twitter_mentions(ums: &[MentionEntity]) -> HashMap<String, String> {
ums.iter().map(|s| ums.iter()
(format!("@{}", s.screen_name), format!("@{}@twitter.com", s.screen_name)) .map(|s| {
).collect() (
format!("@{}", s.screen_name),
format!("@{}@twitter.com", s.screen_name),
)
})
.collect()
} }
/// Decodes urls from UrlEntities /// Decodes urls from UrlEntities
fn decode_urls(urls: &Vec<UrlEntity>) -> HashMap<String, String> { fn decode_urls(urls: &[UrlEntity]) -> HashMap<String, String> {
urls.iter() urls.iter()
.filter(|s| s.expanded_url.is_some()) .filter(|s| s.expanded_url.is_some())
.map(|s| .map(|s| {
(s.url.to_owned(), s.expanded_url.as_deref().unwrap().to_owned()) (
).collect() s.url.to_owned(),
s.expanded_url.as_deref().unwrap().to_owned(),
)
})
.collect()
} }
/// Gets Mastodon Data /// Gets Mastodon Data
@@ -64,38 +65,57 @@ pub fn build_basic_status(tweet: &Tweet) -> String {
/// Generic register function /// Generic register function
/// As this function is supposed to be run only once, it will panic for every error it encounters /// 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 `elefren` crate /// Most of this function is a direct copy/paste of the official `elefren` crate
pub fn register(host: &str) { pub fn register(host: &str, screen_name: &str) {
let mut builder = App::builder(); let mut builder = App::builder();
builder.client_name(Cow::from(env!("CARGO_PKG_NAME").to_string())) builder
.client_name(Cow::from(env!("CARGO_PKG_NAME").to_string()))
.redirect_uris(Cow::from("urn:ietf:wg:oauth:2.0:oob".to_string())) .redirect_uris(Cow::from("urn:ietf:wg:oauth:2.0:oob".to_string()))
.scopes(Scopes::write_all()) .scopes(Scopes::write_all())
.website(Cow::from("https://framagit.org/veretcle/scootaloo".to_string())); .website(Cow::from(
"https://framagit.org/veretcle/scootaloo".to_string(),
));
let app = builder.build().expect("Cannot build the app"); let app = builder.build().expect("Cannot build the app");
let registration = Registration::new(host).register(app).expect("Cannot build registration object"); let registration = Registration::new(host)
let url = registration.authorize_url().expect("Cannot generate registration URI!"); .register(app)
.expect("Cannot build registration object");
let url = registration
.authorize_url()
.expect("Cannot generate registration URI!");
println!("Click this link to authorize on Mastodon: {}", url); println!("Click this link to authorize on Mastodon: {}", url);
println!("Paste the returned authorization code: "); println!("Paste the returned authorization code: ");
let mut input = String::new(); let mut input = String::new();
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 code = input.trim();
let mastodon = registration.complete(code).expect("Unable to create access token!"); let mastodon = registration
.complete(code)
.expect("Unable to create access token!");
let toml = toml::to_string(&*mastodon).unwrap(); let toml = toml::to_string(&*mastodon).unwrap();
println!("Please insert the following block at the end of your configuration file:\n[mastodon]\n{}", toml); println!(
"Please insert the following block at the end of your configuration file:
[mastodon.{}]
twitter_screen_name = \"{}\"
{}",
screen_name.to_lowercase(),
screen_name,
toml
);
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use egg_mode::tweet::TweetEntities;
use chrono::prelude::*; use chrono::prelude::*;
use egg_mode::tweet::TweetEntities;
#[test] #[test]
fn test_twitter_mentions() { fn test_twitter_mentions() {
@@ -109,7 +129,10 @@ mod tests {
let twitter_ums = vec![mention_entity]; let twitter_ums = vec![mention_entity];
let mut expected_mentions = HashMap::new(); let mut expected_mentions = HashMap::new();
expected_mentions.insert("@tamerelol".to_string(), "@tamerelol@twitter.com".to_string()); expected_mentions.insert(
"@tamerelol".to_string(),
"@tamerelol@twitter.com".to_string(),
);
let decoded_mentions = twitter_mentions(&twitter_ums); let decoded_mentions = twitter_mentions(&twitter_ums);
@@ -135,7 +158,10 @@ mod tests {
let twitter_urls = vec![url_entity1, url_entity2]; let twitter_urls = vec![url_entity1, url_entity2];
let mut expected_urls = HashMap::new(); let mut expected_urls = HashMap::new();
expected_urls.insert("https://t.me/tamerelol".to_string(), "https://www.nintendojo.fr/dojobar".to_string()); expected_urls.insert(
"https://t.me/tamerelol".to_string(),
"https://www.nintendojo.fr/dojobar".to_string(),
);
let decoded_urls = decode_urls(&twitter_urls); let decoded_urls = decode_urls(&twitter_urls);
@@ -200,4 +226,3 @@ mod tests {
assert_eq!(&t_out, "Mother 1 & 2 sur le NES/SNES online !\nDispo maintenant. cc @NintendoFrance@twitter.com https://www.youtube.com/watch?v=w5TrSaoYmZ8"); assert_eq!(&t_out, "Mother 1 & 2 sur le NES/SNES online !\nDispo maintenant. cc @NintendoFrance@twitter.com https://www.youtube.com/watch?v=w5TrSaoYmZ8");
} }
} }

View File

@@ -1,32 +1,41 @@
use std::error::Error; use std::error::Error;
use log::debug; use log::debug;
use rusqlite::{Connection, params, OptionalExtension};
use rusqlite::{params, Connection, OptionalExtension};
/// Struct for each query line /// Struct for each query line
#[derive(Debug)] #[derive(Debug)]
pub struct TweetToToot { pub struct TweetToToot {
pub twitter_screen_name: String,
pub tweet_id: u64, pub tweet_id: u64,
pub toot_id: String, pub toot_id: String,
} }
/// if None is passed, read the last tweet from DB /// if None is passed, read the last tweet from DB
/// if a tweet_id is passed, read this particular tweet from DB /// if a tweet_id is passed, read this particular tweet from DB
pub fn read_state(conn: &Connection, s: Option<u64>) -> Result<Option<TweetToToot>, Box<dyn Error>> { pub fn read_state(
conn: &Connection,
n: &str,
s: Option<u64>,
) -> Result<Option<TweetToToot>, Box<dyn Error>> {
debug!("Reading tweet_id {:?}", s); debug!("Reading tweet_id {:?}", s);
let query: String; let query: String = match s {
match s { Some(i) => format!("SELECT * FROM tweet_to_toot WHERE tweet_id = {} and twitter_screen_name = \"{}\"", i, n),
Some(i) => query = format!("SELECT * FROM tweet_to_toot WHERE tweet_id = {}", i), None => format!("SELECT * FROM tweet_to_toot WHERE twitter_screen_name = \"{}\" ORDER BY tweet_id DESC LIMIT 1", n),
None => query = "SELECT * FROM tweet_to_toot ORDER BY tweet_id DESC LIMIT 1".to_string(),
}; };
let mut stmt = conn.prepare(&query)?; let mut stmt = conn.prepare(&query)?;
let t = stmt.query_row([], |row| { let t = stmt
Ok(TweetToToot { .query_row([], |row| {
tweet_id: row.get(0)?, Ok(TweetToToot {
toot_id: row.get(1)?, twitter_screen_name: row.get("twitter_screen_name")?,
tweet_id: row.get("tweet_id")?,
toot_id: row.get("toot_id")?,
})
}) })
}).optional()?; .optional()?;
Ok(t) Ok(t)
} }
@@ -35,9 +44,9 @@ pub fn read_state(conn: &Connection, s: Option<u64>) -> Result<Option<TweetToToo
pub fn write_state(conn: &Connection, t: TweetToToot) -> Result<(), Box<dyn Error>> { pub fn write_state(conn: &Connection, t: TweetToToot) -> Result<(), Box<dyn Error>> {
debug!("Write struct {:?}", t); debug!("Write struct {:?}", t);
conn.execute( conn.execute(
"INSERT INTO tweet_to_toot (tweet_id, toot_id) VALUES (?1, ?2)", "INSERT INTO tweet_to_toot (twitter_screen_name, tweet_id, toot_id) VALUES (?1, ?2, ?3)",
params![t.tweet_id, t.toot_id], params![t.twitter_screen_name, t.tweet_id, t.toot_id],
)?; )?;
Ok(()) Ok(())
} }
@@ -49,8 +58,9 @@ pub fn init_db(d: &str) -> Result<(), Box<dyn Error>> {
conn.execute( conn.execute(
"CREATE TABLE IF NOT EXISTS tweet_to_toot ( "CREATE TABLE IF NOT EXISTS tweet_to_toot (
tweet_id INTEGER PRIMARY KEY, twitter_screen_name TEXT NOT NULL,
toot_id TEXT UNIQUE tweet_id INTEGER PRIMARY KEY,
toot_id TEXT UNIQUE
)", )",
[], [],
)?; )?;
@@ -58,13 +68,35 @@ pub fn init_db(d: &str) -> Result<(), Box<dyn Error>> {
Ok(()) Ok(())
} }
/// Migrate DB from 0.6.x to 0.7.x
pub fn migrate_db(d: &str, s: &str) -> Result<(), Box<dyn Error>> {
debug!("Migrating DB for Scootaloo");
let conn = Connection::open(d)?;
let res = conn.execute(
&format!(
"ALTER TABLE tweet_to_toot
ADD COLUMN twitter_screen_name TEXT NOT NULL
DEFAULT \"{}\"",
s
),
[],
);
match res {
Err(e) => match e.to_string().as_str() {
"duplicate column name: twitter_screen_name" => Ok(()),
_ => Err(Box::new(e)),
},
_ => Ok(()),
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::{ use std::{fs::remove_file, path::Path};
fs::remove_file,
path::Path,
};
#[test] #[test]
fn test_init_db() { fn test_init_db() {
@@ -77,10 +109,7 @@ mod tests {
// open said file // open said file
let conn = Connection::open(d).unwrap(); let conn = Connection::open(d).unwrap();
conn.execute( conn.execute("SELECT * from tweet_to_toot;", []).unwrap();
"SELECT * from tweet_to_toot;",
[],
).unwrap();
remove_file(d).unwrap(); remove_file(d).unwrap();
} }
@@ -95,11 +124,12 @@ mod tests {
let conn = Connection::open(d).unwrap(); let conn = Connection::open(d).unwrap();
conn.execute( conn.execute(
"INSERT INTO tweet_to_toot "INSERT INTO tweet_to_toot (twitter_screen_name, tweet_id, toot_id)
VALUES VALUES
(100, 'A');", ('tamerelol', 100, 'A');",
[], [],
).unwrap(); )
.unwrap();
init_db(d).unwrap(); init_db(d).unwrap();
@@ -115,6 +145,7 @@ mod tests {
let conn = Connection::open(d).unwrap(); let conn = Connection::open(d).unwrap();
let t_in = TweetToToot { let t_in = TweetToToot {
twitter_screen_name: "tamerelol".to_string(),
tweet_id: 123456789, tweet_id: 123456789,
toot_id: "987654321".to_string(), toot_id: "987654321".to_string(),
}; };
@@ -123,15 +154,19 @@ mod tests {
let mut stmt = conn.prepare("SELECT * FROM tweet_to_toot;").unwrap(); let mut stmt = conn.prepare("SELECT * FROM tweet_to_toot;").unwrap();
let t_out = stmt.query_row([], |row| { let t_out = stmt
Ok(TweetToToot { .query_row([], |row| {
tweet_id: row.get(0).unwrap(), Ok(TweetToToot {
toot_id: row.get(1).unwrap(), twitter_screen_name: row.get("twitter_screen_name").unwrap(),
tweet_id: row.get("tweet_id").unwrap(),
toot_id: row.get("toot_id").unwrap(),
})
}) })
}).unwrap(); .unwrap();
assert_eq!(&t_out.twitter_screen_name, "tamerelol");
assert_eq!(t_out.tweet_id, 123456789); assert_eq!(t_out.tweet_id, 123456789);
assert_eq!(t_out.toot_id, "987654321".to_string()); assert_eq!(&t_out.toot_id, "987654321");
remove_file(d).unwrap(); remove_file(d).unwrap();
} }
@@ -145,14 +180,15 @@ mod tests {
let conn = Connection::open(d).unwrap(); let conn = Connection::open(d).unwrap();
conn.execute( conn.execute(
"INSERT INTO tweet_to_toot (tweet_id, toot_id) "INSERT INTO tweet_to_toot (twitter_screen_name, tweet_id, toot_id)
VALUES VALUES
(101, 'A'), ('tamerelol', 101, 'A'),
(102, 'B');", ('tamerelol', 102, 'B');",
[], [],
).unwrap(); )
.unwrap();
let t_out = read_state(&conn, None).unwrap().unwrap(); let t_out = read_state(&conn, "tamerelol", None).unwrap().unwrap();
remove_file(d).unwrap(); remove_file(d).unwrap();
@@ -168,7 +204,7 @@ mod tests {
let conn = Connection::open(d).unwrap(); let conn = Connection::open(d).unwrap();
let t_out = read_state(&conn, None).unwrap(); let t_out = read_state(&conn, "tamerelol", None).unwrap();
remove_file(d).unwrap(); remove_file(d).unwrap();
@@ -184,13 +220,14 @@ mod tests {
let conn = Connection::open(d).unwrap(); let conn = Connection::open(d).unwrap();
conn.execute( conn.execute(
"INSERT INTO tweet_to_toot (tweet_id, toot_id) "INSERT INTO tweet_to_toot (twitter_screen_name, tweet_id, toot_id)
VALUES VALUES
(100, 'A');", ('tamerelol', 100, 'A');",
[], [],
).unwrap(); )
.unwrap();
let t_out = read_state(&conn, Some(101)).unwrap(); let t_out = read_state(&conn, "tamerelol", Some(101)).unwrap();
remove_file(d).unwrap(); remove_file(d).unwrap();
@@ -206,18 +243,62 @@ mod tests {
let conn = Connection::open(d).unwrap(); let conn = Connection::open(d).unwrap();
conn.execute( conn.execute(
"INSERT INTO tweet_to_toot (tweet_id, toot_id) "INSERT INTO tweet_to_toot (twitter_screen_name, tweet_id, toot_id)
VALUES VALUES
(100, 'A');", ('tamerelol', 100, 'A');",
[], [],
).unwrap(); )
.unwrap();
let t_out = read_state(&conn, Some(100)).unwrap().unwrap(); let t_out = read_state(&conn, "tamerelol", Some(100)).unwrap().unwrap();
remove_file(d).unwrap(); remove_file(d).unwrap();
assert_eq!(t_out.tweet_id, 100); assert_eq!(t_out.tweet_id, 100);
assert_eq!(t_out.toot_id, "A"); assert_eq!(t_out.toot_id, "A");
} }
}
#[test]
fn test_migrate_db_add_column() {
let d = "/tmp/test_migrate_db_add_column.sqlite";
let conn = Connection::open(d).unwrap();
conn.execute(
"CREATE TABLE IF NOT EXISTS tweet_to_toot (
tweet_id INTEGER PRIMARY KEY,
toot_id TEXT UNIQUE
)",
[],
)
.unwrap();
migrate_db(d, "tamerelol").unwrap();
let mut stmt = conn.prepare("PRAGMA table_info(tweet_to_toot);").unwrap();
let mut t = stmt.query([]).unwrap();
while let Some(row) = t.next().unwrap() {
if row.get::<usize, u8>(0).unwrap() == 2 {
assert_eq!(
row.get::<usize, String>(1).unwrap(),
"twitter_screen_name".to_string()
);
}
}
remove_file(d).unwrap();
}
#[test]
fn test_migrate_db_no_add_column() {
let d = "/tmp/test_migrate_db_no_add_column.sqlite";
init_db(d).unwrap();
migrate_db(d, "tamerelol").unwrap();
remove_file(d).unwrap();
}
}

View File

@@ -1,23 +1,25 @@
use crate::ScootalooError;
use crate::config::TwitterConfig; use crate::config::TwitterConfig;
use crate::util::cache_media; use crate::util::cache_media;
use crate::ScootalooError;
use std::error::Error;
use egg_mode::{ use egg_mode::{
Token,
KeyPair,
entities::{MediaEntity, MediaType}, entities::{MediaEntity, MediaType},
tweet::{user_timeline, Tweet},
user::UserID, user::UserID,
tweet::{ KeyPair, Token,
Tweet,
user_timeline,
},
}; };
use std::error::Error;
/// Gets Twitter oauth2 token /// Gets Twitter oauth2 token
pub fn get_oauth2_token(config: &TwitterConfig) -> Token { pub fn get_oauth2_token(config: &TwitterConfig) -> Token {
let con_token = KeyPair::new(config.consumer_key.to_owned(),config.consumer_secret.to_owned()); let con_token = KeyPair::new(
let access_token = KeyPair::new(config.access_key.to_owned(), config.access_secret.to_owned()); config.consumer_key.to_owned(),
config.consumer_secret.to_owned(),
);
let access_token = KeyPair::new(
config.access_key.to_owned(),
config.access_secret.to_owned(),
);
Token::Access { Token::Access {
consumer: con_token, consumer: con_token,
@@ -26,12 +28,16 @@ pub fn get_oauth2_token(config: &TwitterConfig) -> Token {
} }
/// Gets Twitter user timeline /// Gets Twitter user timeline
pub async fn get_user_timeline(config: &TwitterConfig, token: Token, lid: Option<u64>) -> Result<Vec<Tweet>, Box<dyn Error>> { pub async fn get_user_timeline(
screen_name: &str,
token: &Token,
lid: Option<u64>,
) -> Result<Vec<Tweet>, Box<dyn Error>> {
// fix the page size to 200 as it is the maximum Twitter authorizes // fix the page size to 200 as it is the maximum Twitter authorizes
let (_, feed) = user_timeline(UserID::from(config.username.to_owned()), true, false, &token) let (_, feed) = user_timeline(UserID::from(screen_name.to_owned()), true, false, token)
.with_page_size(200) .with_page_size(200)
.older(lid) .older(lid)
.await?; .await?;
Ok(feed.to_vec()) Ok(feed.to_vec())
} }
@@ -39,24 +45,137 @@ pub async fn get_user_timeline(config: &TwitterConfig, token: Token, lid: Option
/// Retrieves a single media from a tweet and store it in a temporary file /// Retrieves a single media from a tweet and store it in a temporary file
pub async fn get_tweet_media(m: &MediaEntity, t: &str) -> Result<String, Box<dyn Error>> { pub async fn get_tweet_media(m: &MediaEntity, t: &str) -> Result<String, Box<dyn Error>> {
match m.media_type { match m.media_type {
MediaType::Photo => { MediaType::Photo => cache_media(&m.media_url_https, t).await,
return cache_media(&m.media_url_https, t).await; _ => match &m.video_info {
Some(v) => match &v.variants.iter().find(|&x| x.content_type == "video/mp4") {
Some(u) => cache_media(&u.url, t).await,
None => Err(ScootalooError::new(&format!(
"Media Type for {} is video but no mp4 file URL is available",
&m.url
))
.into()),
},
None => Err(ScootalooError::new(&format!(
"Media Type for {} is video but does not contain any video_info",
&m.url
))
.into()),
}, },
_ => { }
match &m.video_info {
Some(v) => {
for variant in &v.variants {
if variant.content_type == "video/mp4" {
return cache_media(&variant.url, t).await;
}
}
return Err(ScootalooError::new(&format!("Media Type for {} is video but no mp4 file URL is available", &m.url)).into());
},
None => {
return Err(ScootalooError::new(&format!("Media Type for {} is video but does not contain any video_info", &m.url)).into());
},
}
},
};
} }
#[cfg(test)]
mod tests {
use super::*;
use egg_mode::entities::{
MediaSize, MediaSizes,
MediaType::{Gif, Photo},
ResizeMode::Crop,
ResizeMode::Fit,
VideoInfo, VideoVariant,
};
use std::fs::remove_dir_all;
const TMP_DIR: &'static str = "/tmp/scootaloo_get_tweet_media_test";
#[tokio::test]
async fn test_get_tweet_media() {
let m_photo = MediaEntity {
display_url: "pic.twitter.com/sHrwmP69Yv".to_string(),
expanded_url: "https://twitter.com/NintendojoFR/status/1555473821121056771/photo/1"
.to_string(),
id: 1555473771280080896,
range: (91, 114),
media_url: "http://pbs.twimg.com/media/FZYnJ1qWIAAReHt.jpg".to_string(),
media_url_https: "https://pbs.twimg.com/media/FZYnJ1qWIAAReHt.jpg"
.to_string(),
sizes: MediaSizes {
thumb: MediaSize {
w: 150,
h: 150,
resize: Crop
},
small: MediaSize {
w: 680,
h: 510,
resize: Fit
},
medium: MediaSize {
w: 1200,
h: 900,
resize: Fit
},
large: MediaSize {
w: 1280,
h: 960,
resize: Fit
}
},
source_status_id: None,
media_type: Photo,
url: "https://t.co/sHrwmP69Yv".to_string(),
video_info: None,
ext_alt_text: Some("Le menu «\u{a0}Classes » du jeu vidéo Xenoblade Chronicles 3 (Switch). Laffinité du personnage pour la classe est notée par quatre lettres : C, A, C, A (caca)."
.to_string())
};
let m_video = MediaEntity {
display_url: "pic.twitter.com/xDln0RrkjU".to_string(),
expanded_url: "https://twitter.com/NintendojoFR/status/1551822196833673218/photo/1"
.to_string(),
id: 1551822189711790081,
range: (275, 298),
media_url: "http://pbs.twimg.com/tweet_video_thumb/FYkuD0RXEAE-iDx.jpg".to_string(),
media_url_https: "https://pbs.twimg.com/tweet_video_thumb/FYkuD0RXEAE-iDx.jpg"
.to_string(),
sizes: MediaSizes {
thumb: MediaSize {
w: 150,
h: 150,
resize: Crop,
},
small: MediaSize {
w: 320,
h: 240,
resize: Fit,
},
medium: MediaSize {
w: 320,
h: 240,
resize: Fit,
},
large: MediaSize {
w: 320,
h: 240,
resize: Fit,
},
},
source_status_id: None,
media_type: Gif,
url: "https://t.co/xDln0RrkjU".to_string(),
video_info: Some(VideoInfo {
aspect_ratio: (4, 3),
duration_millis: None,
variants: vec![VideoVariant {
bitrate: Some(0),
content_type: "video/mp4".parse::<mime::Mime>().unwrap(),
url: "https://video.twimg.com/tweet_video/FYkuD0RXEAE-iDx.mp4".to_string(),
}],
}),
ext_alt_text: Some("Scared Nintendo GIF".to_string()),
};
let tweet_media_photo = get_tweet_media(&m_photo, TMP_DIR).await.unwrap();
let tweet_media_video = get_tweet_media(&m_video, TMP_DIR).await.unwrap();
assert_eq!(
tweet_media_photo,
format!("{}/FZYnJ1qWIAAReHt.jpg", TMP_DIR)
);
assert_eq!(
tweet_media_video,
format!("{}/FYkuD0RXEAE-iDx.mp4", TMP_DIR)
);
remove_dir_all(TMP_DIR).unwrap();
}
}

View File

@@ -1,11 +1,100 @@
use std::error::Error; use crate::{twitter::get_tweet_media, ScootalooError};
use crate::ScootalooError;
use std::{borrow::Cow, error::Error};
use egg_mode::tweet::Tweet;
use elefren::prelude::*;
use log::{error, info, warn};
use reqwest::Url; use reqwest::Url;
use tokio::{ use tokio::{
fs::{create_dir_all, remove_file, File},
io::copy, io::copy,
fs::{File, create_dir_all},
}; };
/// Generate associative table between media ids and tweet extended entities
pub async fn generate_media_ids(
tweet: &Tweet,
cache_path: &str,
mastodon: &Mastodon,
) -> (String, Vec<String>) {
let mut media_url = "".to_string();
let mut media_ids: Vec<String> = vec![];
if let Some(m) = &tweet.extended_entities {
// create tasks list
let mut tasks = vec![];
// size of media_ids vector, should be equal to the media vector
media_ids.resize(m.media.len(), String::new());
info!("{} medias in tweet", m.media.len());
for (i, media) in m.media.iter().enumerate() {
// attribute media url
media_url = media.url.clone();
// clone everything we need
let cache_path = String::from(cache_path);
let media = media.clone();
let mastodon = mastodon.clone();
let task = tokio::task::spawn(async move {
info!("Start treating {}", media.media_url_https);
// get the tweet embedded media
let local_tweet_media_path = match get_tweet_media(&media, &cache_path).await {
Ok(l) => l,
Err(e) => {
return Err(ScootalooError::new(&format!(
"Cannot get tweet media for {}: {}",
&media.url, e
)))
}
};
// upload media to Mastodon
let mastodon_media = mastodon.media(Cow::from(local_tweet_media_path.to_owned()));
// at this point, we can safely erase the original file
// it doesnt matter if we cant remove, cache_media fn is idempotent
remove_file(&local_tweet_media_path).await.ok();
let mastodon_media = match mastodon_media {
Ok(m) => m,
Err(e) => {
return Err(ScootalooError::new(&format!(
"Attachment {} cannot be uploaded to Mastodon Instance: {}",
&local_tweet_media_path, e
)))
}
};
Ok((i, mastodon_media.id))
});
tasks.push(task);
}
for task in tasks {
match task.await {
// insert the media at the right place
Ok(Ok((i, v))) => media_ids[i] = v,
Ok(Err(e)) => warn!("{}", e),
Err(e) => error!("Something went wrong when joining the main thread: {}", e),
}
}
} else {
info!("No media in tweet");
}
// in case some media_ids slot remained empty due to errors, remove them
media_ids.retain(|x| !x.is_empty());
(media_url, media_ids)
}
/// Gets and caches Twitter Media inside the determined temp dir /// Gets and caches Twitter Media inside the determined temp dir
pub async fn cache_media(u: &str, t: &str) -> Result<String, Box<dyn Error>> { pub async fn cache_media(u: &str, t: &str) -> Result<String, Box<dyn Error>> {
// create dir // create dir
@@ -16,8 +105,21 @@ pub async fn cache_media(u: &str, t: &str) -> Result<String, Box<dyn Error>> {
// create local file // create local file
let url = Url::parse(u)?; let url = Url::parse(u)?;
let dest_filename = url.path_segments().ok_or_else(|| ScootalooError::new(&format!("Cannot determine the destination filename for {}", u)))? let dest_filename = url
.last().ok_or_else(|| ScootalooError::new(&format!("Cannot determine the destination filename for {}", u)))?; .path_segments()
.ok_or_else(|| {
ScootalooError::new(&format!(
"Cannot determine the destination filename for {}",
u
))
})?
.last()
.ok_or_else(|| {
ScootalooError::new(&format!(
"Cannot determine the destination filename for {}",
u
))
})?;
let dest_filepath = format!("{}/{}", t, dest_filename); let dest_filepath = format!("{}/{}", t, dest_filename);
@@ -34,20 +136,21 @@ pub async fn cache_media(u: &str, t: &str) -> Result<String, Box<dyn Error>> {
mod tests { mod tests {
use super::*; use super::*;
use std::{ use std::{fs::remove_dir_all, path::Path};
path::Path,
fs::remove_dir_all,
};
const TMP_DIR: &'static str = "/tmp/scootaloo_test"; const TMP_DIR: &'static str = "/tmp/scootaloo_test";
#[tokio::test] #[tokio::test]
async fn test_cache_media() { async fn test_cache_media() {
let dest = cache_media("https://forum.nintendojo.fr/styles/prosilver/theme/images/ndfr_casual.png", TMP_DIR).await.unwrap(); let dest = cache_media(
"https://forum.nintendojo.fr/styles/prosilver/theme/images/ndfr_casual.png",
TMP_DIR,
)
.await
.unwrap();
assert!(Path::new(&dest).exists()); assert!(Path::new(&dest).exists());
remove_dir_all(TMP_DIR).unwrap(); remove_dir_all(TMP_DIR).unwrap();
} }
} }

View File

@@ -4,30 +4,63 @@ use scootaloo::parse_toml;
fn test_parse_good_toml() { fn test_parse_good_toml() {
let parse_good_toml = parse_toml("tests/good_test.toml"); let parse_good_toml = parse_toml("tests/good_test.toml");
assert_eq!(parse_good_toml.scootaloo.db_path, "/var/random/scootaloo.sqlite"); assert_eq!(
parse_good_toml.scootaloo.db_path,
"/var/random/scootaloo.sqlite"
);
assert_eq!(parse_good_toml.scootaloo.cache_path, "/tmp/scootaloo"); assert_eq!(parse_good_toml.scootaloo.cache_path, "/tmp/scootaloo");
assert_eq!(parse_good_toml.twitter.username, "tamerelol");
assert_eq!(parse_good_toml.twitter.consumer_key, "rand consumer key"); assert_eq!(parse_good_toml.twitter.consumer_key, "rand consumer key");
assert_eq!(parse_good_toml.twitter.consumer_secret, "secret"); assert_eq!(parse_good_toml.twitter.consumer_secret, "secret");
assert_eq!(parse_good_toml.twitter.access_key, "rand access key"); assert_eq!(parse_good_toml.twitter.access_key, "rand access key");
assert_eq!(parse_good_toml.twitter.access_secret, "super secret"); assert_eq!(parse_good_toml.twitter.access_secret, "super secret");
assert_eq!(parse_good_toml.mastodon.base, "https://m.nintendojo.fr"); assert_eq!(
assert_eq!(parse_good_toml.mastodon.client_id, "rand client id"); &parse_good_toml
assert_eq!(parse_good_toml.mastodon.client_secret, "secret"); .mastodon
assert_eq!(parse_good_toml.mastodon.redirect, "urn:ietf:wg:oauth:2.0:oob"); .get("tamerelol")
assert_eq!(parse_good_toml.mastodon.token, "super secret"); .unwrap()
.twitter_screen_name,
"tamerelol"
);
assert_eq!(
&parse_good_toml.mastodon.get("tamerelol").unwrap().base,
"https://m.nintendojo.fr"
);
assert_eq!(
&parse_good_toml.mastodon.get("tamerelol").unwrap().client_id,
"rand client id"
);
assert_eq!(
&parse_good_toml
.mastodon
.get("tamerelol")
.unwrap()
.client_secret,
"secret"
);
assert_eq!(
&parse_good_toml.mastodon.get("tamerelol").unwrap().redirect,
"urn:ietf:wg:oauth:2.0:oob"
);
assert_eq!(
&parse_good_toml.mastodon.get("tamerelol").unwrap().token,
"super secret"
);
} }
#[test] #[test]
#[should_panic(expected = "Cannot open config file tests/no_file.toml: No such file or directory (os error 2)")] #[should_panic(
expected = "Cannot open config file tests/no_file.toml: No such file or directory (os error 2)"
)]
fn test_parse_no_toml() { fn test_parse_no_toml() {
let _parse_no_toml = parse_toml("tests/no_file.toml"); let _parse_no_toml = parse_toml("tests/no_file.toml");
} }
#[test] #[test]
#[should_panic(expected = "Cannot parse TOML file tests/bad_test.toml: expected an equals, found a newline at line 1 column 5")] #[should_panic(
expected = "Cannot parse TOML file tests/bad_test.toml: expected an equals, found a newline at line 1 column 5"
)]
fn test_parse_bad_toml() { fn test_parse_bad_toml() {
let _parse_bad_toml = parse_toml("tests/bad_test.toml"); let _parse_bad_toml = parse_toml("tests/bad_test.toml");
} }

View File

@@ -4,14 +4,14 @@ db_path="/var/random/scootaloo.sqlite"
cache_path="/tmp/scootaloo" cache_path="/tmp/scootaloo"
[twitter] [twitter]
username="tamerelol"
consumer_key="rand consumer key" consumer_key="rand consumer key"
consumer_secret="secret" consumer_secret="secret"
access_key="rand access key" access_key="rand access key"
access_secret="super secret" access_secret="super secret"
[mastodon] [mastodon]
[mastodon.tamerelol]
twitter_screen_name="tamerelol"
base = "https://m.nintendojo.fr" base = "https://m.nintendojo.fr"
client_id = "rand client id" client_id = "rand client id"
client_secret = "secret" client_secret = "secret"