mirror of
https://framagit.org/veretcle/scootaloo.git
synced 2025-07-20 17:11:19 +02:00
Compare commits
90 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
3413f49d08 | ||
![]() |
d4fbccc69b | ||
![]() |
058a07865d | ||
![]() |
c6a29d1d7d | ||
![]() |
3692d6e51f | ||
![]() |
5fe57f189a | ||
![]() |
83c398cebf | ||
![]() |
8f567ed6b4 | ||
![]() |
d7431862ba | ||
![]() |
f3b13eb62f | ||
![]() |
6b68c8e299 | ||
![]() |
0bb5eabdac | ||
![]() |
3d44bbfb86 | ||
![]() |
9a03c7681b | ||
![]() |
a8a8f8c13f | ||
![]() |
90a9df220a | ||
![]() |
6218c59ce5 | ||
![]() |
6ffcbfc89a | ||
![]() |
3fdd81df50 | ||
![]() |
90f47079d9 | ||
![]() |
88b73f4bc5 | ||
![]() |
87797c7ab0 | ||
![]() |
3645728ddf | ||
![]() |
69648728d7 | ||
![]() |
6af1e4c55a | ||
![]() |
8d55ea69a2 | ||
![]() |
b5b0a63f67 | ||
![]() |
0f5ab4158c | ||
![]() |
25f98581a5 | ||
![]() |
7f42c9d01a | ||
![]() |
19f75a9e76 | ||
![]() |
6e23e0ab14 | ||
![]() |
c3862fea55 | ||
![]() |
c0ae9dc52f | ||
![]() |
2ae87b2767 | ||
![]() |
0399623cfa | ||
![]() |
895c41c75f | ||
![]() |
63830be0d5 | ||
![]() |
5633bf9187 | ||
![]() |
f42aa8cbb6 | ||
![]() |
1132f41b9e | ||
![]() |
70f8c14e99 | ||
![]() |
faab50d1ea | ||
![]() |
9cafa2bf07 | ||
![]() |
9227850c99 | ||
![]() |
64d72ea69d | ||
![]() |
9dd6ab8370 | ||
![]() |
4679578101 | ||
![]() |
2501d5990f | ||
![]() |
cb36730151 | ||
![]() |
a9942fad5c | ||
![]() |
522d4e3ea5 | ||
![]() |
91e3cd04a0 | ||
![]() |
87a7574d42 | ||
![]() |
18e8b9d306 | ||
![]() |
1e9c768a74 | ||
![]() |
83a133bb86 | ||
![]() |
92d5fdffad | ||
![]() |
331adec60f | ||
![]() |
9a341310da | ||
![]() |
2c77a0e5fc | ||
![]() |
032e3cf8dd | ||
![]() |
a854243cf6 | ||
![]() |
b33ffa4401 | ||
![]() |
77941e0b9a | ||
![]() |
1489f89bdb | ||
![]() |
93a27deae8 | ||
![]() |
fe3745d91f | ||
![]() |
9a1e4c8e6c | ||
![]() |
8b12f83c5d | ||
![]() |
f93bb5158b | ||
![]() |
d5db8b0d85 | ||
![]() |
fe8e81b54d | ||
![]() |
636ea8c85e | ||
![]() |
b3e7ee9d84 | ||
![]() |
7f7219ea78 | ||
![]() |
f371b8a297 | ||
![]() |
ec3956eabb | ||
![]() |
ce84c05581 | ||
![]() |
b64621368b | ||
![]() |
89de1cf7a3 | ||
![]() |
ffbe98f838 | ||
![]() |
822f4044c6 | ||
![]() |
78924f6eeb | ||
![]() |
9c14636735 | ||
![]() |
01bac63fb9 | ||
![]() |
4f5663b450 | ||
![]() |
9a9c4b4809 | ||
![]() |
9970968b47 | ||
![]() |
291c86677e |
2624
Cargo.lock
generated
2624
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
15
Cargo.toml
15
Cargo.toml
@@ -1,21 +1,22 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "scootaloo"
|
name = "scootaloo"
|
||||||
version = "0.8.0"
|
version = "1.1.6"
|
||||||
authors = ["VC <veretcle+framagit@mateu.be>"]
|
authors = ["VC <veretcle+framagit@mateu.be>"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
chrono = "^0.4"
|
base64 = "^0.13"
|
||||||
|
regex = "^1"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
toml = "^0.5"
|
toml = "^0.5"
|
||||||
clap = "^2.34"
|
clap = "^4"
|
||||||
futures = "^0.3"
|
|
||||||
egg-mode = "^0.16"
|
egg-mode = "^0.16"
|
||||||
rusqlite = "^0.27"
|
rusqlite = "^0.27"
|
||||||
tokio = { version = "1", features = ["full"]}
|
tokio = { version = "^1", features = ["rt"]}
|
||||||
elefren = "^0.22"
|
futures = "^0.3"
|
||||||
|
megalodon = "^0.3.6"
|
||||||
html-escape = "^0.2"
|
html-escape = "^0.2"
|
||||||
reqwest = "^0.11"
|
reqwest = "^0.11"
|
||||||
log = "^0.4"
|
log = "^0.4"
|
||||||
@@ -24,3 +25,5 @@ mime = "^0.3"
|
|||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
strip = true
|
strip = true
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
55
README.md
55
README.md
@@ -1,3 +1,9 @@
|
|||||||
|
**Due to the new Twitter policy about API v1.1 and API v2, this project will no longer be updated as it no longer can work for free any way, shape or form.**
|
||||||
|
|
||||||
|
First of all, I’m deeply sorry about that. It worked pretty great for what it did with a level of quality I’m very proud to have achieved.
|
||||||
|
|
||||||
|
Secondly, fuck you Musk, fuck you.
|
||||||
|
|
||||||
A Twitter to Mastodon copy bot written in Rust
|
A Twitter to Mastodon copy bot written in Rust
|
||||||
|
|
||||||
It:
|
It:
|
||||||
@@ -18,9 +24,24 @@ First up, create a configuration file (default path is `/usr/local/etc/scootaloo
|
|||||||
[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
|
||||||
|
rate_limiting = 4 ## optional, default 4, number of accounts handled simultaneously
|
||||||
|
## optional, this should be omitted the majority of the time
|
||||||
|
## sometimes, twitter try to use french inclusive writting, but instead of using `·` (median point), they’re using `.`
|
||||||
|
## this makes twitter interpret it as a URL, which is wrong
|
||||||
|
## this parameter allows you to catch such URLs and apply the `display_url` (i.e. `tout.es`) instead of the `expanded_url` (i.e. `http://tout.es`)
|
||||||
|
## in those particular cases
|
||||||
|
## (!) use with caution, it might have some undesired effects
|
||||||
|
show_url_as_display_url_for = "^https?://(.+)\\.es$"
|
||||||
|
## optional, this allows you to replace the host for popular services such as YouTube of Twitter, or any other
|
||||||
|
## with their more freely accessible equivalent
|
||||||
|
[scootaloo.alternative_services_for]
|
||||||
|
"tamere.lol" = "tonpere.mdr" ## quotes are necessary for both parameters
|
||||||
|
"you.pi" = "you.pla"
|
||||||
|
"www.you.pi" = "you.pla" ## this is an exact match, so you’ll need to lay out all the possibilities
|
||||||
|
|
||||||
[twitter]
|
[twitter]
|
||||||
## 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)
|
||||||
|
page_size = 20 ## optional, default 200, max number of tweet retrieved
|
||||||
consumer_key = "MYCONSUMERKEY"
|
consumer_key = "MYCONSUMERKEY"
|
||||||
consumer_secret = "MYCONSUMERSECRET"
|
consumer_secret = "MYCONSUMERSECRET"
|
||||||
access_key = "MYACCESSKEY"
|
access_key = "MYACCESSKEY"
|
||||||
@@ -45,7 +66,8 @@ This will give you the end of the TOML file. It will look like this:
|
|||||||
|
|
||||||
```toml
|
```toml
|
||||||
[mastodon.nintendojofr] ## account
|
[mastodon.nintendojofr] ## account
|
||||||
twitter_screen_name="NintendojoFR" ## User Timeline to copy
|
twitter_screen_name = "NintendojoFR" ## User Timeline to copy
|
||||||
|
mastodon_screen_name = "nintendojofr" ## optional, Mastodon account name used for smart mentions
|
||||||
base = "https://m.nintendojo.fr"
|
base = "https://m.nintendojo.fr"
|
||||||
client_id = "MYCLIENTID"
|
client_id = "MYCLIENTID"
|
||||||
client_secret = "MYCLIENTSECRET"
|
client_secret = "MYCLIENTSECRET"
|
||||||
@@ -55,6 +77,11 @@ token = "MYTOKEN"
|
|||||||
|
|
||||||
You can add other account if you like, after the `[mastodon]` moniker. Scootaloo would theorically support an unlimited number of accounts.
|
You can add other account if you like, after the `[mastodon]` moniker. Scootaloo would theorically support an unlimited number of accounts.
|
||||||
|
|
||||||
|
You can also add a custom twitter page size in this section that would override the global (under the `twitter` moniker) and default one (200), like so:
|
||||||
|
```
|
||||||
|
twitter_page_size = 40
|
||||||
|
```
|
||||||
|
|
||||||
## Running
|
## 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:
|
||||||
@@ -62,22 +89,20 @@ You can then run the application via `cron` for example. Here is the generic usa
|
|||||||
```sh
|
```sh
|
||||||
A Twitter to Mastodon bot
|
A Twitter to Mastodon bot
|
||||||
|
|
||||||
USAGE:
|
Usage: scootaloo [OPTIONS] [COMMAND]
|
||||||
scootaloo [OPTIONS] [SUBCOMMAND]
|
|
||||||
|
|
||||||
FLAGS:
|
Commands:
|
||||||
-h, --help Prints help information
|
register Command to register to a Mastodon Instance
|
||||||
-V, --version Prints version information
|
init Command to init Scootaloo DB
|
||||||
|
migrate Command to migrate Scootaloo DB
|
||||||
|
copyprofile Command to copy a Twitter profile into Mastodon
|
||||||
|
help Print this message or the help of the given subcommand(s)
|
||||||
|
|
||||||
OPTIONS:
|
Options:
|
||||||
-c, --config <CONFIG_FILE> TOML config file for scootaloo (default /usr/local/etc/scootaloo.toml)
|
-c, --config <CONFIG_FILE> TOML config file for scootaloo [default: /usr/local/etc/scootaloo.toml]
|
||||||
-l, --loglevel <LOGLEVEL> Log level. Valid values are: Off, Warn, Error, Info, Debug
|
-l, --loglevel <LOGLEVEL> Log level [possible values: Off, Warn, Error, Info, Debug]
|
||||||
|
-h, --help Print help information
|
||||||
SUBCOMMANDS:
|
-V, --version Print version information
|
||||||
help Prints this message or the help of the given subcommand(s)
|
|
||||||
init Command to init Scootaloo DB
|
|
||||||
migrate Command to migrate Scootaloo DB
|
|
||||||
register Command to register to a Mastodon Instance
|
|
||||||
```
|
```
|
||||||
|
|
||||||
# Quirks
|
# Quirks
|
||||||
|
@@ -16,11 +16,14 @@ pub struct TwitterConfig {
|
|||||||
pub consumer_secret: String,
|
pub consumer_secret: String,
|
||||||
pub access_key: String,
|
pub access_key: String,
|
||||||
pub access_secret: String,
|
pub access_secret: String,
|
||||||
|
pub page_size: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
pub struct MastodonConfig {
|
pub struct MastodonConfig {
|
||||||
pub twitter_screen_name: String,
|
pub twitter_screen_name: String,
|
||||||
|
pub mastodon_screen_name: Option<String>,
|
||||||
|
pub twitter_page_size: Option<i32>,
|
||||||
pub base: String,
|
pub base: String,
|
||||||
pub client_id: String,
|
pub client_id: String,
|
||||||
pub client_secret: String,
|
pub client_secret: String,
|
||||||
@@ -32,15 +35,18 @@ pub struct MastodonConfig {
|
|||||||
pub struct ScootalooConfig {
|
pub struct ScootalooConfig {
|
||||||
pub db_path: String,
|
pub db_path: String,
|
||||||
pub cache_path: String,
|
pub cache_path: String,
|
||||||
|
pub rate_limit: Option<usize>,
|
||||||
|
pub show_url_as_display_url_for: Option<String>,
|
||||||
|
pub alternative_services_for: Option<HashMap<String, String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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)
|
let toml_config = read_to_string(toml_file)
|
||||||
.unwrap_or_else(|e| 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)
|
let config: Config = toml::from_str(&toml_config)
|
||||||
.unwrap_or_else(|e| panic!("Cannot parse TOML file {}: {}", toml_file, e));
|
.unwrap_or_else(|e| panic!("Cannot parse TOML file {toml_file}: {e}"));
|
||||||
|
|
||||||
config
|
config
|
||||||
}
|
}
|
||||||
|
16
src/error.rs
16
src/error.rs
@@ -1,8 +1,12 @@
|
|||||||
use std::{
|
use std::{
|
||||||
|
boxed::Box,
|
||||||
|
convert::From,
|
||||||
error::Error,
|
error::Error,
|
||||||
fmt::{Display, Formatter, Result},
|
fmt::{Display, Formatter, Result},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use megalodon::error::Error as megalodonError;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct ScootalooError {
|
pub struct ScootalooError {
|
||||||
details: String,
|
details: String,
|
||||||
@@ -23,3 +27,15 @@ impl Display for ScootalooError {
|
|||||||
write!(f, "{}", self.details)
|
write!(f, "{}", self.details)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<megalodonError> for ScootalooError {
|
||||||
|
fn from(error: megalodonError) -> Self {
|
||||||
|
ScootalooError::new(&format!("Error in megalodon crate: {error}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
377
src/lib.rs
377
src/lib.rs
@@ -7,30 +7,35 @@ use config::Config;
|
|||||||
|
|
||||||
mod mastodon;
|
mod mastodon;
|
||||||
pub use mastodon::register;
|
pub use mastodon::register;
|
||||||
use mastodon::{build_basic_status, get_mastodon_token};
|
use mastodon::*;
|
||||||
|
|
||||||
mod twitter;
|
mod twitter;
|
||||||
use twitter::*;
|
use twitter::*;
|
||||||
|
|
||||||
mod util;
|
mod util;
|
||||||
use crate::util::generate_media_ids;
|
use util::{base64_media, generate_media_ids};
|
||||||
|
|
||||||
mod state;
|
mod state;
|
||||||
pub use state::{init_db, migrate_db};
|
pub use state::{init_db, migrate_db};
|
||||||
use state::{read_state, write_state, TweetToToot};
|
use state::{read_state, write_state, TweetToToot};
|
||||||
|
|
||||||
use elefren::{prelude::*, status_builder::StatusBuilder};
|
use futures::StreamExt;
|
||||||
|
use html_escape::decode_html_entities;
|
||||||
use log::info;
|
use log::info;
|
||||||
|
use megalodon::{
|
||||||
|
megalodon::PostStatusInputOptions, megalodon::UpdateCredentialsInputOptions, Megalodon,
|
||||||
|
};
|
||||||
|
use regex::Regex;
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::{spawn, sync::Mutex};
|
use tokio::{spawn, sync::Mutex};
|
||||||
|
|
||||||
|
const DEFAULT_RATE_LIMIT: usize = 4;
|
||||||
|
const DEFAULT_PAGE_SIZE: i32 = 200;
|
||||||
|
|
||||||
/// 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 = Arc::new(Mutex::new(
|
let conn = Arc::new(Mutex::new(
|
||||||
Connection::open(&config.scootaloo.db_path).unwrap_or_else(|e| {
|
Connection::open(&config.scootaloo.db_path).unwrap_or_else(|e| {
|
||||||
@@ -41,121 +46,267 @@ pub async fn run(config: Config) {
|
|||||||
}),
|
}),
|
||||||
));
|
));
|
||||||
|
|
||||||
for mastodon_config in config.mastodon.into_values() {
|
let global_mastodon_config = Arc::new(Mutex::new(config.mastodon.clone()));
|
||||||
// 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();
|
|
||||||
|
|
||||||
let task = spawn(async move {
|
let display_url_re = config
|
||||||
info!("Starting treating {}", &mastodon_config.twitter_screen_name);
|
.scootaloo
|
||||||
|
.show_url_as_display_url_for
|
||||||
|
.as_ref()
|
||||||
|
.map(|r|
|
||||||
|
// we want to panic in case the RE is not valid
|
||||||
|
Regex::new(r).unwrap());
|
||||||
|
|
||||||
// retrieve the last tweet ID for the username
|
let mut stream = futures::stream::iter(config.mastodon.into_values())
|
||||||
let lconn = task_conn.lock().await;
|
.map(|mastodon_config| {
|
||||||
let last_tweet_id = read_state(&lconn, &mastodon_config.twitter_screen_name, None)
|
// calculate Twitter page size
|
||||||
.unwrap_or_else(|e| panic!("Cannot retrieve last_tweet_id: {}", e))
|
let page_size = mastodon_config
|
||||||
.map(|s| s.tweet_id);
|
.twitter_page_size
|
||||||
drop(lconn);
|
.unwrap_or_else(|| config.twitter.page_size.unwrap_or(DEFAULT_PAGE_SIZE));
|
||||||
|
|
||||||
// get Mastodon instance
|
// create temporary value for each task
|
||||||
let mastodon = get_mastodon_token(&mastodon_config);
|
let scootaloo_cache_path = config.scootaloo.cache_path.clone();
|
||||||
|
let scootaloo_alt_services = config.scootaloo.alternative_services_for.clone();
|
||||||
|
let display_url_re = display_url_re.clone();
|
||||||
|
let token = get_oauth2_token(&config.twitter);
|
||||||
|
let task_conn = conn.clone();
|
||||||
|
let global_mastodon_config = global_mastodon_config.clone();
|
||||||
|
|
||||||
// get user timeline feed (Vec<tweet>)
|
spawn(async move {
|
||||||
let mut feed =
|
info!("Starting treating {}", &mastodon_config.twitter_screen_name);
|
||||||
get_user_timeline(&mastodon_config.twitter_screen_name, &token, last_tweet_id)
|
// retrieve the last tweet ID for the username
|
||||||
.await
|
|
||||||
.unwrap_or_else(|e| {
|
|
||||||
panic!(
|
|
||||||
"Something went wrong when trying to retrieve {}’s timeline: {}",
|
|
||||||
&mastodon_config.twitter_screen_name, e
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
// empty feed -> exiting
|
|
||||||
if feed.is_empty() {
|
|
||||||
info!("Nothing to retrieve since last time, exiting…");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// order needs to be chronological
|
|
||||||
feed.reverse();
|
|
||||||
|
|
||||||
for tweet in &feed {
|
|
||||||
info!("Treating Tweet {} inside feed", tweet.id);
|
|
||||||
// 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() != mastodon_config.twitter_screen_name.to_lowercase() {
|
|
||||||
// we are responding not threading
|
|
||||||
info!("Tweet is a direct response, skipping");
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
// build basic status by just yielding text and dereferencing contained urls
|
|
||||||
let mut status_text = build_basic_status(tweet);
|
|
||||||
|
|
||||||
// building associative media list
|
|
||||||
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()
|
|
||||||
.unwrap_or_else(|_| panic!("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 {
|
|
||||||
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;
|
let lconn = task_conn.lock().await;
|
||||||
write_state(&lconn, ttt_towrite)
|
let last_tweet_id = read_state(&lconn, &mastodon_config.twitter_screen_name, None)?
|
||||||
.unwrap_or_else(|e| panic!("Can’t write the last tweet retrieved: {}", e));
|
.map(|r| r.tweet_id);
|
||||||
drop(lconn);
|
drop(lconn);
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// push each task into the vec task
|
// get reversed, curated user timeline
|
||||||
mtask.push(task);
|
let feed = get_user_timeline(
|
||||||
}
|
&mastodon_config.twitter_screen_name,
|
||||||
|
&token,
|
||||||
|
last_tweet_id,
|
||||||
|
page_size,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// get Mastodon instance
|
||||||
|
let mastodon = get_mastodon_token(&mastodon_config);
|
||||||
|
|
||||||
|
for tweet in &feed {
|
||||||
|
info!("Treating Tweet {} inside feed", tweet.id);
|
||||||
|
|
||||||
|
// basic toot text
|
||||||
|
let mut status_text = tweet.text.clone();
|
||||||
|
|
||||||
|
// add mentions and smart mentions
|
||||||
|
if !&tweet.entities.user_mentions.is_empty() {
|
||||||
|
info!("Tweet contains mentions, add them!");
|
||||||
|
let global_mastodon_config = global_mastodon_config.lock().await;
|
||||||
|
twitter_mentions(
|
||||||
|
&mut status_text,
|
||||||
|
&tweet.entities.user_mentions,
|
||||||
|
&global_mastodon_config,
|
||||||
|
);
|
||||||
|
drop(global_mastodon_config);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !&tweet.entities.urls.is_empty() {
|
||||||
|
info!("Tweet contains links, add them!");
|
||||||
|
let mut associated_urls =
|
||||||
|
associate_urls(&tweet.entities.urls, &display_url_re);
|
||||||
|
|
||||||
|
if let Some(q) = &tweet.quoted_status {
|
||||||
|
if let Some(u) = &q.user {
|
||||||
|
info!(
|
||||||
|
"Tweet {} contains a quote, we try to find it within the DB",
|
||||||
|
tweet.id
|
||||||
|
);
|
||||||
|
// we know we have a quote and a user, we can lock both the
|
||||||
|
// connection to DB and global_config
|
||||||
|
// we will release them manually as soon as they’re useless
|
||||||
|
let lconn = task_conn.lock().await;
|
||||||
|
let global_mastodon_config = global_mastodon_config.lock().await;
|
||||||
|
if let Ok(Some(r)) = read_state(&lconn, &u.screen_name, Some(q.id))
|
||||||
|
{
|
||||||
|
info!("We have found the associated toot({})", &r.toot_id);
|
||||||
|
// drop conn immediately after the request: we won’t need it
|
||||||
|
// any more and the treatment there might be time-consuming
|
||||||
|
drop(lconn);
|
||||||
|
if let Some((m, t)) =
|
||||||
|
find_mastodon_screen_name_by_twitter_screen_name(
|
||||||
|
&r.twitter_screen_name,
|
||||||
|
&global_mastodon_config,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
// drop the global conf, we have all we required, no need
|
||||||
|
// to block it further
|
||||||
|
drop(global_mastodon_config);
|
||||||
|
replace_tweet_by_toot(
|
||||||
|
&mut associated_urls,
|
||||||
|
&r.twitter_screen_name,
|
||||||
|
q.id,
|
||||||
|
&m,
|
||||||
|
&t,
|
||||||
|
&r.toot_id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(a) = &scootaloo_alt_services {
|
||||||
|
replace_alt_services(&mut associated_urls, a);
|
||||||
|
}
|
||||||
|
|
||||||
|
decode_urls(&mut status_text, &associated_urls);
|
||||||
|
}
|
||||||
|
|
||||||
|
// building associative media list
|
||||||
|
let (media_url, status_medias) =
|
||||||
|
generate_media_ids(tweet, &scootaloo_cache_path, &mastodon).await;
|
||||||
|
|
||||||
|
status_text = status_text.replace(&media_url, "");
|
||||||
|
|
||||||
|
// now that the text won’t be altered anymore, we can safely remove HTML
|
||||||
|
// entities
|
||||||
|
status_text = decode_html_entities(&status_text).to_string();
|
||||||
|
|
||||||
|
info!("Building corresponding Mastodon status");
|
||||||
|
|
||||||
|
let mut post_status = PostStatusInputOptions {
|
||||||
|
media_ids: None,
|
||||||
|
poll: None,
|
||||||
|
in_reply_to_id: None,
|
||||||
|
sensitive: None,
|
||||||
|
spoiler_text: None,
|
||||||
|
visibility: None,
|
||||||
|
scheduled_at: None,
|
||||||
|
language: None,
|
||||||
|
quote_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if !status_medias.is_empty() {
|
||||||
|
post_status.media_ids = Some(status_medias);
|
||||||
|
}
|
||||||
|
|
||||||
|
// thread if necessary
|
||||||
|
if tweet.in_reply_to_user_id.is_some() {
|
||||||
|
let lconn = task_conn.lock().await;
|
||||||
|
if let Ok(Some(r)) = read_state(
|
||||||
|
&lconn,
|
||||||
|
&mastodon_config.twitter_screen_name,
|
||||||
|
tweet.in_reply_to_status_id,
|
||||||
|
) {
|
||||||
|
post_status.in_reply_to_id = Some(r.toot_id.to_owned());
|
||||||
|
}
|
||||||
|
drop(lconn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// language if any
|
||||||
|
if let Some(l) = &tweet.lang {
|
||||||
|
post_status.language = Some(l.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// can be activated for test purposes
|
||||||
|
// post_status.visibility = Some(megalodon::entities::StatusVisibility::Direct);
|
||||||
|
|
||||||
|
let published_status = mastodon
|
||||||
|
.post_status(status_text, Some(&post_status))
|
||||||
|
.await?
|
||||||
|
.json();
|
||||||
|
// 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>(())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.buffer_unordered(config.scootaloo.rate_limit.unwrap_or(DEFAULT_RATE_LIMIT));
|
||||||
|
|
||||||
// launch and wait for every handle
|
// launch and wait for every handle
|
||||||
for handle in mtask {
|
while let Some(result) = stream.next().await {
|
||||||
handle.await.unwrap();
|
match result {
|
||||||
|
Ok(Err(e)) => eprintln!("Error within thread: {e}"),
|
||||||
|
Err(e) => eprintln!("Error with thread: {e}"),
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copies the Twitter profile into Mastodon
|
||||||
|
#[tokio::main]
|
||||||
|
pub async fn profile(config: Config, bot: Option<bool>) {
|
||||||
|
let mut stream = futures::stream::iter(config.mastodon.into_values())
|
||||||
|
.map(|mastodon_config| {
|
||||||
|
let token = get_oauth2_token(&config.twitter);
|
||||||
|
|
||||||
|
spawn(async move {
|
||||||
|
// get the user of the last tweet of the feed
|
||||||
|
let twitter_user =
|
||||||
|
get_user_timeline(&mastodon_config.twitter_screen_name, &token, None, 1)
|
||||||
|
.await?
|
||||||
|
.first()
|
||||||
|
.ok_or_else(|| ScootalooError::new("Can’t extract a tweet from the feed!"))?
|
||||||
|
.clone()
|
||||||
|
.user
|
||||||
|
.ok_or_else(|| ScootalooError::new("No user in Tweet!"))?;
|
||||||
|
|
||||||
|
let note = get_note_from_description(
|
||||||
|
&twitter_user.description,
|
||||||
|
&twitter_user.entities.description.urls,
|
||||||
|
);
|
||||||
|
|
||||||
|
let fields_attributes = get_attribute_from_url(&twitter_user.entities.url);
|
||||||
|
|
||||||
|
let display_name = Some(String::from_utf16_lossy(
|
||||||
|
&twitter_user
|
||||||
|
.name
|
||||||
|
.encode_utf16()
|
||||||
|
.take(30)
|
||||||
|
.collect::<Vec<u16>>(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let header = match twitter_user.profile_banner_url {
|
||||||
|
Some(h) => Some(base64_media(&h).await?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let update_creds = UpdateCredentialsInputOptions {
|
||||||
|
bot,
|
||||||
|
display_name,
|
||||||
|
note,
|
||||||
|
avatar: Some(
|
||||||
|
base64_media(&twitter_user.profile_image_url_https.replace("_normal", ""))
|
||||||
|
.await?,
|
||||||
|
),
|
||||||
|
header,
|
||||||
|
fields_attributes,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mastodon = get_mastodon_token(&mastodon_config);
|
||||||
|
|
||||||
|
mastodon.update_credentials(Some(&update_creds)).await?;
|
||||||
|
|
||||||
|
Ok::<(), ScootalooError>(())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.buffer_unordered(config.scootaloo.rate_limit.unwrap_or(DEFAULT_RATE_LIMIT));
|
||||||
|
|
||||||
|
while let Some(result) = stream.next().await {
|
||||||
|
match result {
|
||||||
|
Ok(Err(e)) => eprintln!("Error within thread: {e}"),
|
||||||
|
Err(e) => eprintln!("Error with thread: {e}"),
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
161
src/main.rs
161
src/main.rs
@@ -1,5 +1,5 @@
|
|||||||
use clap::{App, Arg, SubCommand};
|
use clap::{Arg, ArgAction, Command};
|
||||||
use log::{error, LevelFilter};
|
use log::LevelFilter;
|
||||||
use scootaloo::*;
|
use scootaloo::*;
|
||||||
use simple_logger::SimpleLogger;
|
use simple_logger::SimpleLogger;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
@@ -7,136 +7,177 @@ use std::str::FromStr;
|
|||||||
const DEFAULT_CONFIG_PATH: &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 = Command::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(
|
||||||
Arg::with_name("config")
|
Arg::new("config")
|
||||||
.short("c")
|
.short('c')
|
||||||
.long("config")
|
.long("config")
|
||||||
.value_name("CONFIG_FILE")
|
.value_name("CONFIG_FILE")
|
||||||
.help(&format!(
|
.help("TOML config file for scootaloo")
|
||||||
"TOML config file for scootaloo (default {})",
|
.num_args(1)
|
||||||
DEFAULT_CONFIG_PATH
|
.default_value(DEFAULT_CONFIG_PATH)
|
||||||
))
|
|
||||||
.takes_value(true)
|
|
||||||
.display_order(1),
|
.display_order(1),
|
||||||
)
|
)
|
||||||
.arg(
|
.arg(
|
||||||
Arg::with_name("log_level")
|
Arg::new("log_level")
|
||||||
.short("l")
|
.short('l')
|
||||||
.long("loglevel")
|
.long("loglevel")
|
||||||
.value_name("LOGLEVEL")
|
.value_name("LOGLEVEL")
|
||||||
.help("Log level. Valid values are: Off, Warn, Error, Info, Debug")
|
.help("Log level")
|
||||||
.takes_value(true)
|
.num_args(1)
|
||||||
|
.value_parser(["Off", "Warn", "Error", "Info", "Debug"])
|
||||||
.display_order(2),
|
.display_order(2),
|
||||||
)
|
)
|
||||||
.subcommand(
|
.subcommand(
|
||||||
SubCommand::with_name("register")
|
Command::new("register")
|
||||||
.version(env!("CARGO_PKG_VERSION"))
|
.version(env!("CARGO_PKG_VERSION"))
|
||||||
.about("Command to register to a Mastodon Instance")
|
.about("Command to register to a Mastodon Instance")
|
||||||
.arg(
|
.arg(
|
||||||
Arg::with_name("host")
|
Arg::new("host")
|
||||||
.short("H")
|
.short('H')
|
||||||
.long("host")
|
.long("host")
|
||||||
.value_name("HOST")
|
.value_name("HOST")
|
||||||
.help("Base URL of the Mastodon instance to register to (no default)")
|
.help("Base URL of the Mastodon instance to register to (no default)")
|
||||||
.takes_value(true)
|
.num_args(1)
|
||||||
.required(true)
|
.required(true)
|
||||||
.display_order(1)
|
.display_order(1)
|
||||||
)
|
)
|
||||||
.arg(
|
.arg(
|
||||||
Arg::with_name("name")
|
Arg::new("name")
|
||||||
.short("n")
|
.short('n')
|
||||||
.long("name")
|
.long("name")
|
||||||
.help("Twitter Screen Name (like https://twitter.com/screen_name, no default)")
|
.help("Twitter Screen Name (like https://twitter.com/screen_name, no default)")
|
||||||
.takes_value(true)
|
.num_args(1)
|
||||||
.required(true)
|
.required(true)
|
||||||
.display_order(2)
|
.display_order(2)
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.subcommand(
|
.subcommand(
|
||||||
SubCommand::with_name("init")
|
Command::new("init")
|
||||||
.version(env!("CARGO_PKG_VERSION"))
|
.version(env!("CARGO_PKG_VERSION"))
|
||||||
.about("Command to init Scootaloo DB")
|
.about("Command to init Scootaloo DB")
|
||||||
.arg(
|
.arg(
|
||||||
Arg::with_name("config")
|
Arg::new("config")
|
||||||
.short("c")
|
.short('c')
|
||||||
.long("config")
|
.long("config")
|
||||||
.value_name("CONFIG_FILE")
|
.value_name("CONFIG_FILE")
|
||||||
.help(&format!(
|
.help("TOML config file for scootaloo")
|
||||||
"TOML config file for scootaloo (default {})",
|
.default_value(DEFAULT_CONFIG_PATH)
|
||||||
DEFAULT_CONFIG_PATH
|
.num_args(1)
|
||||||
))
|
|
||||||
.takes_value(true)
|
|
||||||
.display_order(1),
|
.display_order(1),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.subcommand(
|
.subcommand(
|
||||||
SubCommand::with_name("migrate")
|
Command::new("migrate")
|
||||||
.version(env!("CARGO_PKG_VERSION"))
|
.version(env!("CARGO_PKG_VERSION"))
|
||||||
.about("Command to migrate Scootaloo DB")
|
.about("Command to migrate Scootaloo DB")
|
||||||
.arg(
|
.arg(
|
||||||
Arg::with_name("config")
|
Arg::new("config")
|
||||||
.short("c")
|
.short('c')
|
||||||
.long("config")
|
.long("config")
|
||||||
.value_name("CONFIG_FILE")
|
.value_name("CONFIG_FILE")
|
||||||
.help(&format!("TOML config file for scootaloo (default {})", DEFAULT_CONFIG_PATH))
|
.help("TOML config file for scootaloo")
|
||||||
.takes_value(true)
|
.default_value(DEFAULT_CONFIG_PATH)
|
||||||
|
.num_args(1)
|
||||||
.display_order(1),
|
.display_order(1),
|
||||||
)
|
)
|
||||||
.arg(
|
.arg(
|
||||||
Arg::with_name("name")
|
Arg::new("name")
|
||||||
.short("n")
|
.short('n')
|
||||||
.long("name")
|
.long("name")
|
||||||
.help("Twitter Screen Name (like https://twitter.com/screen_name, no default)")
|
.help("Twitter Screen Name (like https://twitter.com/screen_name, no default)")
|
||||||
.takes_value(true)
|
.num_args(1)
|
||||||
.display_order(2)
|
.display_order(2)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.subcommand(
|
||||||
|
Command::new("copyprofile")
|
||||||
|
.version(env!("CARGO_PKG_VERSION"))
|
||||||
|
.about("Command to copy a Twitter profile into Mastodon")
|
||||||
|
.arg(
|
||||||
|
Arg::new("config")
|
||||||
|
.short('c')
|
||||||
|
.long("config")
|
||||||
|
.value_name("CONFIG_FILE")
|
||||||
|
.help("TOML config file for scootaloo")
|
||||||
|
.default_value(DEFAULT_CONFIG_PATH)
|
||||||
|
.num_args(1)
|
||||||
|
.display_order(1),
|
||||||
|
)
|
||||||
|
.arg(
|
||||||
|
Arg::new("name")
|
||||||
|
.short('n')
|
||||||
|
.long("name")
|
||||||
|
.help("Mastodon Config name (as seen in the config file)")
|
||||||
|
.num_args(1)
|
||||||
|
.display_order(2)
|
||||||
|
)
|
||||||
|
.arg(
|
||||||
|
Arg::new("bot")
|
||||||
|
.short('b')
|
||||||
|
.long("bot")
|
||||||
|
.help("Declare user as bot")
|
||||||
|
.action(ArgAction::SetTrue)
|
||||||
|
.display_order(3)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.get_matches();
|
.get_matches();
|
||||||
|
|
||||||
match matches.subcommand() {
|
match matches.subcommand() {
|
||||||
("register", Some(sub_m)) => {
|
Some(("register", sub_m)) => {
|
||||||
register(
|
register(
|
||||||
sub_m.value_of("host").unwrap(),
|
sub_m.get_one::<String>("host").unwrap(),
|
||||||
sub_m.value_of("name").unwrap(),
|
sub_m.get_one::<String>("name").unwrap(),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
("init", Some(sub_m)) => {
|
Some(("init", sub_m)) => {
|
||||||
let config = parse_toml(sub_m.value_of("config").unwrap_or(DEFAULT_CONFIG_PATH));
|
let config = parse_toml(sub_m.get_one::<String>("config").unwrap());
|
||||||
init_db(&config.scootaloo.db_path).unwrap();
|
init_db(&config.scootaloo.db_path).unwrap();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
("migrate", Some(sub_m)) => {
|
Some(("migrate", sub_m)) => {
|
||||||
let config = parse_toml(sub_m.value_of("config").unwrap_or(DEFAULT_CONFIG_PATH));
|
let config = parse_toml(sub_m.get_one::<String>("config").unwrap());
|
||||||
let config_twitter_screen_name =
|
let config_twitter_screen_name =
|
||||||
&config.mastodon.values().next().unwrap().twitter_screen_name;
|
&config.mastodon.values().next().unwrap().twitter_screen_name;
|
||||||
migrate_db(
|
migrate_db(
|
||||||
&config.scootaloo.db_path,
|
&config.scootaloo.db_path,
|
||||||
sub_m.value_of("name").unwrap_or(config_twitter_screen_name),
|
sub_m
|
||||||
|
.get_one::<String>("name")
|
||||||
|
.unwrap_or(config_twitter_screen_name),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
Some(("copyprofile", sub_m)) => {
|
||||||
|
let mut config = parse_toml(sub_m.get_one::<String>("config").unwrap());
|
||||||
|
// filters out the user passed in cli from the global configuration
|
||||||
|
if let Some(m) = sub_m.get_one::<String>("name") {
|
||||||
|
match config.mastodon.get(m) {
|
||||||
|
Some(_) => {
|
||||||
|
config.mastodon.retain(|k, _| k == m);
|
||||||
|
}
|
||||||
|
None => panic!("Config file does not contain conf for {}", &m),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
profile(config, sub_m.get_flag("bot").then_some(true));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
_ => (),
|
_ => (),
|
||||||
}
|
}
|
||||||
|
|
||||||
if matches.is_present("log_level") {
|
if let Some(level) = matches.get_one::<String>("log_level") {
|
||||||
match LevelFilter::from_str(matches.value_of("log_level").unwrap()) {
|
SimpleLogger::new()
|
||||||
Ok(level) => SimpleLogger::new().with_level(level).init().unwrap(),
|
.with_level(LevelFilter::from_str(level).unwrap())
|
||||||
Err(e) => {
|
.init()
|
||||||
SimpleLogger::new()
|
.unwrap();
|
||||||
.with_level(LevelFilter::Error)
|
|
||||||
.init()
|
|
||||||
.unwrap();
|
|
||||||
error!("Unknown log level filter: {}", e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let config = parse_toml(matches.value_of("config").unwrap_or(DEFAULT_CONFIG_PATH));
|
let config = parse_toml(matches.get_one::<String>("config").unwrap());
|
||||||
|
|
||||||
run(config);
|
run(config);
|
||||||
}
|
}
|
||||||
|
652
src/mastodon.rs
652
src/mastodon.rs
@@ -2,89 +2,189 @@ use crate::config::MastodonConfig;
|
|||||||
|
|
||||||
use egg_mode::{
|
use egg_mode::{
|
||||||
entities::{MentionEntity, UrlEntity},
|
entities::{MentionEntity, UrlEntity},
|
||||||
tweet::Tweet,
|
user::UserEntityDetail,
|
||||||
};
|
};
|
||||||
use elefren::{apps::App, prelude::*, scopes::Scopes};
|
use megalodon::{
|
||||||
use html_escape::decode_html_entities;
|
generator,
|
||||||
use std::{borrow::Cow, collections::HashMap, io::stdin};
|
mastodon::Mastodon,
|
||||||
|
megalodon::{AppInputOptions, CredentialsFieldAttribute},
|
||||||
|
};
|
||||||
|
use regex::Regex;
|
||||||
|
use std::{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. Users in the global user list of Scootaloo are rewritten, as they are Mastodon users
|
||||||
fn twitter_mentions(ums: &[MentionEntity]) -> HashMap<String, String> {
|
/// as well
|
||||||
ums.iter()
|
pub fn twitter_mentions(
|
||||||
|
toot: &mut String,
|
||||||
|
ums: &[MentionEntity],
|
||||||
|
masto: &HashMap<String, MastodonConfig>,
|
||||||
|
) {
|
||||||
|
let tm: HashMap<String, String> = ums
|
||||||
|
.iter()
|
||||||
.map(|s| {
|
.map(|s| {
|
||||||
(
|
(
|
||||||
format!("@{}", s.screen_name),
|
format!("@{}", s.screen_name),
|
||||||
format!("@{}@twitter.com", s.screen_name),
|
format!("@{}@twitter.com", s.screen_name),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect()
|
.chain(
|
||||||
|
masto
|
||||||
|
.values()
|
||||||
|
.filter(|s| s.mastodon_screen_name.is_some())
|
||||||
|
.map(|s| {
|
||||||
|
(
|
||||||
|
format!("@{}", s.twitter_screen_name),
|
||||||
|
format!(
|
||||||
|
"@{}@{}",
|
||||||
|
s.mastodon_screen_name.as_ref().unwrap(),
|
||||||
|
s.base.split('/').last().unwrap()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<HashMap<String, String>>(),
|
||||||
|
)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for (k, v) in tm {
|
||||||
|
*toot = toot.replace(&k, &v);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decodes urls from UrlEntities
|
/// Decodes urls in toot
|
||||||
fn decode_urls(urls: &[UrlEntity]) -> HashMap<String, String> {
|
pub fn decode_urls(toot: &mut String, urls: &HashMap<String, String>) {
|
||||||
|
for (k, v) in urls {
|
||||||
|
*toot = toot.replace(k, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reassociates source url with destination url for rewritting
|
||||||
|
/// this takes a Teet UrlEntity and an optional Regex
|
||||||
|
pub fn associate_urls(urls: &[UrlEntity], re: &Option<Regex>) -> 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.url.to_owned(),
|
let mut def = s.expanded_url.as_deref().unwrap().to_owned();
|
||||||
s.expanded_url.as_deref().unwrap().to_owned(),
|
|
||||||
)
|
if let Some(r) = re {
|
||||||
|
if r.is_match(s.expanded_url.as_deref().unwrap()) {
|
||||||
|
def = s.display_url.to_owned();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.collect()
|
.collect::<HashMap<String, String>>()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the commonly used services by mirrors, if asked to
|
||||||
|
pub fn replace_alt_services(urls: &mut HashMap<String, String>, alts: &HashMap<String, String>) {
|
||||||
|
for val in urls.values_mut() {
|
||||||
|
for (k, v) in alts {
|
||||||
|
*val = val.replace(&format!("/{k}/"), &format!("/{v}/"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finds a Mastodon screen_name/base_url from a MastodonConfig
|
||||||
|
pub fn find_mastodon_screen_name_by_twitter_screen_name(
|
||||||
|
twitter_screen_name: &str,
|
||||||
|
masto: &HashMap<String, MastodonConfig>,
|
||||||
|
) -> Option<(String, String)> {
|
||||||
|
masto.iter().find_map(|(_, v)| {
|
||||||
|
if twitter_screen_name == v.twitter_screen_name && v.mastodon_screen_name.is_some() {
|
||||||
|
Some((
|
||||||
|
v.mastodon_screen_name.as_ref().unwrap().to_owned(),
|
||||||
|
v.base.to_owned(),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the original quoted tweet by the corresponding toot
|
||||||
|
pub fn replace_tweet_by_toot(
|
||||||
|
urls: &mut HashMap<String, String>,
|
||||||
|
twitter_screen_name: &str,
|
||||||
|
tweet_id: u64,
|
||||||
|
mastodon_screen_name: &str,
|
||||||
|
base_url: &str,
|
||||||
|
toot_id: &str,
|
||||||
|
) {
|
||||||
|
for val in urls.values_mut() {
|
||||||
|
if val.to_lowercase().starts_with(&format!(
|
||||||
|
"https://twitter.com/{}/status/{}",
|
||||||
|
twitter_screen_name.to_lowercase(),
|
||||||
|
tweet_id
|
||||||
|
)) {
|
||||||
|
*val = format!("{base_url}/@{mastodon_screen_name}/{toot_id}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gets Mastodon Data
|
/// Gets Mastodon Data
|
||||||
pub fn get_mastodon_token(masto: &MastodonConfig) -> Mastodon {
|
pub fn get_mastodon_token(masto: &MastodonConfig) -> Mastodon {
|
||||||
let data = Data {
|
Mastodon::new(masto.base.to_string(), Some(masto.token.to_string()), None)
|
||||||
base: Cow::from(masto.base.to_owned()),
|
|
||||||
client_id: Cow::from(masto.client_id.to_owned()),
|
|
||||||
client_secret: Cow::from(masto.client_secret.to_owned()),
|
|
||||||
redirect: Cow::from(masto.redirect.to_owned()),
|
|
||||||
token: Cow::from(masto.token.to_owned()),
|
|
||||||
};
|
|
||||||
|
|
||||||
Mastodon::from(data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds toot text from tweet
|
/// Gets note from twitter_user description
|
||||||
pub fn build_basic_status(tweet: &Tweet) -> String {
|
pub fn get_note_from_description(t: &Option<String>, urls: &[UrlEntity]) -> Option<String> {
|
||||||
let mut toot = tweet.text.to_owned();
|
t.as_ref().map(|d| {
|
||||||
|
let mut n = d.to_owned();
|
||||||
|
let a_urls = associate_urls(urls, &None);
|
||||||
|
decode_urls(&mut n, &a_urls);
|
||||||
|
n
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
for decoded_url in decode_urls(&tweet.entities.urls) {
|
/// Gets fields_attribute from UserEntityDetail
|
||||||
toot = toot.replace(&decoded_url.0, &decoded_url.1);
|
pub fn get_attribute_from_url(
|
||||||
}
|
user_entity_detail: &Option<UserEntityDetail>,
|
||||||
|
) -> Option<Vec<CredentialsFieldAttribute>> {
|
||||||
for decoded_mention in twitter_mentions(&tweet.entities.user_mentions) {
|
user_entity_detail.as_ref().and_then(|u| {
|
||||||
toot = toot.replace(&decoded_mention.0, &decoded_mention.1);
|
u.urls.first().and_then(|v| {
|
||||||
}
|
v.expanded_url.as_ref().map(|e| {
|
||||||
|
vec![CredentialsFieldAttribute {
|
||||||
decode_html_entities(&toot).to_string()
|
name: v.display_url.to_string(),
|
||||||
|
value: e.to_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, screen_name: &str) {
|
#[tokio::main]
|
||||||
let mut builder = App::builder();
|
pub async fn register(host: &str, screen_name: &str) {
|
||||||
builder
|
let mastodon = generator(megalodon::SNS::Mastodon, host.to_string(), None, None);
|
||||||
.client_name(Cow::from(env!("CARGO_PKG_NAME").to_string()))
|
|
||||||
.redirect_uris(Cow::from("urn:ietf:wg:oauth:2.0:oob".to_string()))
|
|
||||||
.scopes(Scopes::write_all())
|
|
||||||
.website(Cow::from(
|
|
||||||
"https://framagit.org/veretcle/scootaloo".to_string(),
|
|
||||||
));
|
|
||||||
|
|
||||||
let app = builder.build().expect("Cannot build the app");
|
let options = AppInputOptions {
|
||||||
|
redirect_uris: None,
|
||||||
|
scopes: Some(
|
||||||
|
[
|
||||||
|
"read:accounts".to_string(),
|
||||||
|
"write:accounts".to_string(),
|
||||||
|
"write:media".to_string(),
|
||||||
|
"write:statuses".to_string(),
|
||||||
|
]
|
||||||
|
.to_vec(),
|
||||||
|
),
|
||||||
|
website: Some("https://framagit.org/veretcle/scootaloo".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
let registration = Registration::new(host)
|
let app_data = mastodon
|
||||||
.register(app)
|
.register_app(env!("CARGO_PKG_NAME").to_string(), &options)
|
||||||
.expect("Cannot build registration object");
|
.await
|
||||||
let url = registration
|
.expect("Cannot build registration object!");
|
||||||
.authorize_url()
|
|
||||||
.expect("Cannot generate registration URI!");
|
|
||||||
|
|
||||||
println!("Click this link to authorize on Mastodon: {}", url);
|
let url = app_data.url.expect("Cannot generate registration URI!");
|
||||||
|
|
||||||
|
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();
|
||||||
@@ -92,21 +192,47 @@ pub fn register(host: &str, screen_name: &str) {
|
|||||||
.read_line(&mut input)
|
.read_line(&mut input)
|
||||||
.expect("Unable to read back registration code!");
|
.expect("Unable to read back registration code!");
|
||||||
|
|
||||||
let code = input.trim();
|
let token_data = mastodon
|
||||||
let mastodon = registration
|
.fetch_access_token(
|
||||||
.complete(code)
|
app_data.client_id.to_owned(),
|
||||||
|
app_data.client_secret.to_owned(),
|
||||||
|
input.trim().to_string(),
|
||||||
|
megalodon::default::NO_REDIRECT.to_string(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
.expect("Unable to create access token!");
|
.expect("Unable to create access token!");
|
||||||
|
|
||||||
let toml = toml::to_string(&*mastodon).unwrap();
|
let mastodon = generator(
|
||||||
|
megalodon::SNS::Mastodon,
|
||||||
|
host.to_string(),
|
||||||
|
Some(token_data.access_token.to_owned()),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
let current_account = mastodon
|
||||||
|
.verify_account_credentials()
|
||||||
|
.await
|
||||||
|
.expect("Unable to access account information!")
|
||||||
|
.json();
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"Please insert the following block at the end of your configuration file:
|
r#"Please insert the following block at the end of your configuration file:
|
||||||
[mastodon.{}]
|
[mastodon.{}]
|
||||||
twitter_screen_name = \"{}\"
|
twitter_screen_name = "{}"
|
||||||
{}",
|
mastodon_screen_name = "{}"
|
||||||
|
base = "{}"
|
||||||
|
client_id = "{}"
|
||||||
|
client_secret = "{}"
|
||||||
|
redirect = "{}"
|
||||||
|
token = "{}""#,
|
||||||
screen_name.to_lowercase(),
|
screen_name.to_lowercase(),
|
||||||
screen_name,
|
screen_name,
|
||||||
toml
|
current_account.username,
|
||||||
|
host,
|
||||||
|
app_data.client_id,
|
||||||
|
app_data.client_secret,
|
||||||
|
app_data.redirect_uri,
|
||||||
|
token_data.access_token,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,115 +240,343 @@ twitter_screen_name = \"{}\"
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
use chrono::prelude::*;
|
|
||||||
use egg_mode::tweet::TweetEntities;
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_twitter_mentions() {
|
fn test_get_attribute_from_url() {
|
||||||
let mention_entity = MentionEntity {
|
let expected_credentials_field_attribute = CredentialsFieldAttribute {
|
||||||
id: 12345,
|
name: "Nintendojo.fr".to_string(),
|
||||||
range: (1, 3),
|
value: "https://www.nintendojo.fr".to_string(),
|
||||||
name: "Ta Mere l0l".to_string(),
|
|
||||||
screen_name: "tamerelol".to_string(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let twitter_ums = vec![mention_entity];
|
let true_urls = vec![UrlEntity {
|
||||||
|
display_url: "Nintendojo.fr".to_string(),
|
||||||
|
expanded_url: Some("https://www.nintendojo.fr".to_string()),
|
||||||
|
range: (1, 3),
|
||||||
|
url: "https://t.me/balek".to_string(),
|
||||||
|
}];
|
||||||
|
|
||||||
let mut expected_mentions = HashMap::new();
|
let false_urls = vec![UrlEntity {
|
||||||
expected_mentions.insert(
|
display_url: "Nintendojo.fr".to_string(),
|
||||||
"@tamerelol".to_string(),
|
expanded_url: None,
|
||||||
"@tamerelol@twitter.com".to_string(),
|
range: (1, 3),
|
||||||
|
url: "https://t.me/balek".to_string(),
|
||||||
|
}];
|
||||||
|
|
||||||
|
assert!(get_attribute_from_url(&None).is_none());
|
||||||
|
assert!(get_attribute_from_url(&Some(UserEntityDetail { urls: false_urls })).is_none());
|
||||||
|
|
||||||
|
let binding = get_attribute_from_url(&Some(UserEntityDetail { urls: true_urls })).unwrap();
|
||||||
|
let result_credentials_field_attribute = binding.first().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result_credentials_field_attribute.name,
|
||||||
|
expected_credentials_field_attribute.name
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
result_credentials_field_attribute.value,
|
||||||
|
expected_credentials_field_attribute.value
|
||||||
);
|
);
|
||||||
|
|
||||||
let decoded_mentions = twitter_mentions(&twitter_ums);
|
|
||||||
|
|
||||||
assert_eq!(expected_mentions, decoded_mentions);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_decode_urls() {
|
fn test_get_note_from_description() {
|
||||||
let url_entity1 = UrlEntity {
|
let urls = vec![UrlEntity {
|
||||||
display_url: "tamerelol".to_string(),
|
display_url: "tamerelol".to_string(),
|
||||||
expanded_url: Some("https://www.nintendojo.fr/dojobar".to_string()),
|
expanded_url: Some("https://www.nintendojo.fr/dojobar".to_string()),
|
||||||
range: (1, 3),
|
range: (1, 3),
|
||||||
url: "https://t.me/tamerelol".to_string(),
|
url: "https://t.me/tamerelol".to_string(),
|
||||||
};
|
}];
|
||||||
|
|
||||||
let url_entity2 = UrlEntity {
|
let some_description = Some("Youpi | https://t.me/tamerelol".to_string());
|
||||||
display_url: "tamerelol".to_string(),
|
let none_description = None;
|
||||||
expanded_url: None,
|
|
||||||
range: (1, 3),
|
|
||||||
url: "https://t.me/tamerelol".to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let twitter_urls = vec![url_entity1, url_entity2];
|
assert_eq!(
|
||||||
|
get_note_from_description(&some_description, &urls),
|
||||||
let mut expected_urls = HashMap::new();
|
Some("Youpi | 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);
|
assert_eq!(get_note_from_description(&none_description, &urls), None);
|
||||||
|
|
||||||
assert_eq!(expected_urls, decoded_urls);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_build_basic_status() {
|
fn test_replace_tweet_by_toot() {
|
||||||
let t = Tweet {
|
let mut associated_urls = HashMap::from([
|
||||||
coordinates: None,
|
(
|
||||||
created_at: Utc::now(),
|
"https://t.co/perdudeouf".to_string(),
|
||||||
current_user_retweet: None,
|
"https://www.perdu.com".to_string(),
|
||||||
display_text_range: None,
|
),
|
||||||
entities: TweetEntities {
|
(
|
||||||
hashtags: vec![],
|
"https://t.co/realquoteshere".to_string(),
|
||||||
symbols: vec![],
|
"https://twitter.com/nintendojofr/status/1590047921633755136".to_string(),
|
||||||
urls: vec![
|
),
|
||||||
UrlEntity {
|
(
|
||||||
display_url: "youtube.com/watch?v=w5TrSa…".to_string(),
|
"https://t.co/almostthere".to_string(),
|
||||||
expanded_url: Some("https://www.youtube.com/watch?v=w5TrSaoYmZ8".to_string()),
|
"https://twitter.com/NintendojoFR/status/nope".to_string(),
|
||||||
range: (93, 116),
|
),
|
||||||
url: "https://t.co/zXw0FfX2Nt".to_string(),
|
(
|
||||||
}
|
"http://t.co/yetanotherone".to_string(),
|
||||||
],
|
"https://twitter.com/NINTENDOJOFR/status/1590047921633755136".to_string(),
|
||||||
user_mentions: vec![
|
),
|
||||||
MentionEntity {
|
]);
|
||||||
id: 491500016,
|
|
||||||
range: (80, 95),
|
let expected_urls = HashMap::from([
|
||||||
name: "Nintendo France".to_string(),
|
(
|
||||||
screen_name: "NintendoFrance".to_string(),
|
"https://t.co/perdudeouf".to_string(),
|
||||||
}
|
"https://www.perdu.com".to_string(),
|
||||||
],
|
),
|
||||||
media: None,
|
(
|
||||||
|
"https://t.co/realquoteshere".to_string(),
|
||||||
|
"https://m.nintendojo.fr/@nintendojofr/109309605486908797".to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"https://t.co/almostthere".to_string(),
|
||||||
|
"https://twitter.com/NintendojoFR/status/nope".to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"http://t.co/yetanotherone".to_string(),
|
||||||
|
"https://m.nintendojo.fr/@nintendojofr/109309605486908797".to_string(),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
replace_tweet_by_toot(
|
||||||
|
&mut associated_urls,
|
||||||
|
"NintendojoFR",
|
||||||
|
1590047921633755136,
|
||||||
|
"nintendojofr",
|
||||||
|
"https://m.nintendojo.fr",
|
||||||
|
"109309605486908797",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(associated_urls, expected_urls);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_find_mastodon_screen_name_by_twitter_screen_name() {
|
||||||
|
let masto_config = HashMap::from([
|
||||||
|
(
|
||||||
|
"test".to_string(),
|
||||||
|
MastodonConfig {
|
||||||
|
twitter_screen_name: "tonpere".to_string(),
|
||||||
|
mastodon_screen_name: Some("lalali".to_string()),
|
||||||
|
twitter_page_size: None,
|
||||||
|
base: "https://mstdn.net".to_string(),
|
||||||
|
client_id: "".to_string(),
|
||||||
|
client_secret: "".to_string(),
|
||||||
|
redirect: "".to_string(),
|
||||||
|
token: "".to_string(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"test2".to_string(),
|
||||||
|
MastodonConfig {
|
||||||
|
twitter_screen_name: "tamerelol".to_string(),
|
||||||
|
mastodon_screen_name: None,
|
||||||
|
twitter_page_size: None,
|
||||||
|
base: "https://mastoot.fr".to_string(),
|
||||||
|
client_id: "".to_string(),
|
||||||
|
client_secret: "".to_string(),
|
||||||
|
redirect: "".to_string(),
|
||||||
|
token: "".to_string(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"test3".to_string(),
|
||||||
|
MastodonConfig {
|
||||||
|
twitter_screen_name: "NintendojoFR".to_string(),
|
||||||
|
mastodon_screen_name: Some("nintendojofr".to_string()),
|
||||||
|
twitter_page_size: None,
|
||||||
|
base: "https://m.nintendojo.fr".to_string(),
|
||||||
|
client_id: "".to_string(),
|
||||||
|
client_secret: "".to_string(),
|
||||||
|
redirect: "".to_string(),
|
||||||
|
token: "".to_string(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// case sensitiveness, to avoid any mistake
|
||||||
|
assert_eq!(
|
||||||
|
None,
|
||||||
|
find_mastodon_screen_name_by_twitter_screen_name("nintendojofr", &masto_config)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Some((
|
||||||
|
"nintendojofr".to_string(),
|
||||||
|
"https://m.nintendojo.fr".to_string()
|
||||||
|
)),
|
||||||
|
find_mastodon_screen_name_by_twitter_screen_name("NintendojoFR", &masto_config)
|
||||||
|
);
|
||||||
|
// should return None if twitter_screen_name is undefined
|
||||||
|
assert_eq!(
|
||||||
|
None,
|
||||||
|
find_mastodon_screen_name_by_twitter_screen_name("tamerelol", &masto_config)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Some(("lalali".to_string(), "https://mstdn.net".to_string())),
|
||||||
|
find_mastodon_screen_name_by_twitter_screen_name("tonpere", &masto_config)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_twitter_mentions() {
|
||||||
|
let mention_entities = vec![
|
||||||
|
MentionEntity {
|
||||||
|
id: 12345,
|
||||||
|
range: (1, 3),
|
||||||
|
name: "Ta Mere l0l".to_string(),
|
||||||
|
screen_name: "tamerelol".to_string(),
|
||||||
},
|
},
|
||||||
extended_entities: None,
|
MentionEntity {
|
||||||
favorite_count: 0,
|
id: 6789,
|
||||||
favorited: None,
|
range: (1, 3),
|
||||||
filter_level: None,
|
name: "TONPERE".to_string(),
|
||||||
id: 1491541246984306693,
|
screen_name: "tonpere".to_string(),
|
||||||
in_reply_to_user_id: None,
|
},
|
||||||
in_reply_to_screen_name: None,
|
];
|
||||||
in_reply_to_status_id: None,
|
|
||||||
lang: None,
|
|
||||||
place: None,
|
|
||||||
possibly_sensitive: None,
|
|
||||||
quoted_status: None,
|
|
||||||
quoted_status_id: None,
|
|
||||||
retweet_count: 0,
|
|
||||||
retweeted: None,
|
|
||||||
retweeted_status: None,
|
|
||||||
source: None,
|
|
||||||
text: "Mother 1 & 2 sur le NES/SNES online !\nDispo maintenant. cc @NintendoFrance https://t.co/zXw0FfX2Nt".to_string(),
|
|
||||||
truncated: false,
|
|
||||||
user: None,
|
|
||||||
withheld_copyright: false,
|
|
||||||
withheld_in_countries: None,
|
|
||||||
withheld_scope: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let t_out = build_basic_status(&t);
|
let mut toot = ":kikoo: @tamerelol @tonpere !".to_string();
|
||||||
|
|
||||||
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");
|
let masto_config = HashMap::from([(
|
||||||
|
"test".to_string(),
|
||||||
|
(MastodonConfig {
|
||||||
|
twitter_screen_name: "tonpere".to_string(),
|
||||||
|
mastodon_screen_name: Some("lalali".to_string()),
|
||||||
|
twitter_page_size: None,
|
||||||
|
base: "https://mstdn.net".to_string(),
|
||||||
|
client_id: "".to_string(),
|
||||||
|
client_secret: "".to_string(),
|
||||||
|
redirect: "".to_string(),
|
||||||
|
token: "".to_string(),
|
||||||
|
}),
|
||||||
|
)]);
|
||||||
|
|
||||||
|
twitter_mentions(&mut toot, &mention_entities, &masto_config);
|
||||||
|
|
||||||
|
assert_eq!(&toot, ":kikoo: @tamerelol@twitter.com @lalali@mstdn.net !");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_decode_urls() {
|
||||||
|
let urls = HashMap::from([
|
||||||
|
(
|
||||||
|
"https://t.co/thisisatest".to_string(),
|
||||||
|
"https://www.nintendojo.fr/dojobar".to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"https://t.co/nopenotinclusive".to_string(),
|
||||||
|
"invité.es".to_string(),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let mut toot =
|
||||||
|
"Rendez-vous sur https://t.co/thisisatest avec nos https://t.co/nopenotinclusive !"
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
decode_urls(&mut toot, &urls);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
&toot,
|
||||||
|
"Rendez-vous sur https://www.nintendojo.fr/dojobar avec nos invité.es !"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_associate_urls() {
|
||||||
|
let urls = vec![
|
||||||
|
UrlEntity {
|
||||||
|
display_url: "tamerelol".to_string(),
|
||||||
|
expanded_url: Some("https://www.nintendojo.fr/dojobar".to_string()),
|
||||||
|
range: (1, 3),
|
||||||
|
url: "https://t.me/tamerelol".to_string(),
|
||||||
|
},
|
||||||
|
UrlEntity {
|
||||||
|
display_url: "sadcat".to_string(),
|
||||||
|
expanded_url: None,
|
||||||
|
range: (1, 3),
|
||||||
|
url: "https://t.me/sadcat".to_string(),
|
||||||
|
},
|
||||||
|
UrlEntity {
|
||||||
|
display_url: "invité.es".to_string(),
|
||||||
|
expanded_url: Some("http://xn--invit-fsa.es".to_string()),
|
||||||
|
range: (85, 108),
|
||||||
|
url: "https://t.co/WAUgnpHLmo".to_string(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let expected_urls = HashMap::from([
|
||||||
|
(
|
||||||
|
"https://t.me/tamerelol".to_string(),
|
||||||
|
"https://www.nintendojo.fr/dojobar".to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"https://t.co/WAUgnpHLmo".to_string(),
|
||||||
|
"invité.es".to_string(),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let re = Regex::new("(.+)\\.es$").ok();
|
||||||
|
|
||||||
|
let associated_urls = associate_urls(&urls, &re);
|
||||||
|
|
||||||
|
assert_eq!(associated_urls, expected_urls);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_replace_alt_services() {
|
||||||
|
let mut associated_urls = HashMap::from([
|
||||||
|
(
|
||||||
|
"https://t.co/youplaboom".to_string(),
|
||||||
|
"https://www.youtube.com/watch?v=dQw4w9WgXcQ".to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"https://t.co/thisisfine".to_string(),
|
||||||
|
"https://twitter.com/Nintendo/status/1594590628771688448".to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"https://t.co/nopenope".to_string(),
|
||||||
|
"https://www.nintendojo.fr/dojobar".to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"https://t.co/broken".to_string(),
|
||||||
|
"http://youtu.be".to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"https://t.co/alsobroken".to_string(),
|
||||||
|
"https://youtube.com".to_string(),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let alt_services = HashMap::from([
|
||||||
|
("twitter.com".to_string(), "nitter.net".to_string()),
|
||||||
|
("youtu.be".to_string(), "invidio.us".to_string()),
|
||||||
|
("www.youtube.com".to_string(), "invidio.us".to_string()),
|
||||||
|
("youtube.com".to_string(), "invidio.us".to_string()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let expected_urls = HashMap::from([
|
||||||
|
(
|
||||||
|
"https://t.co/youplaboom".to_string(),
|
||||||
|
"https://invidio.us/watch?v=dQw4w9WgXcQ".to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"https://t.co/thisisfine".to_string(),
|
||||||
|
"https://nitter.net/Nintendo/status/1594590628771688448".to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"https://t.co/nopenope".to_string(),
|
||||||
|
"https://www.nintendojo.fr/dojobar".to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"https://t.co/broken".to_string(),
|
||||||
|
"http://youtu.be".to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"https://t.co/alsobroken".to_string(),
|
||||||
|
"https://youtube.com".to_string(),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
replace_alt_services(&mut associated_urls, &alt_services);
|
||||||
|
|
||||||
|
assert_eq!(associated_urls, expected_urls);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -21,8 +21,8 @@ pub fn read_state(
|
|||||||
) -> Result<Option<TweetToToot>, Box<dyn Error>> {
|
) -> Result<Option<TweetToToot>, Box<dyn Error>> {
|
||||||
debug!("Reading tweet_id {:?}", s);
|
debug!("Reading tweet_id {:?}", s);
|
||||||
let query: String = match s {
|
let query: String = match s {
|
||||||
Some(i) => format!("SELECT * FROM tweet_to_toot WHERE tweet_id = {} and twitter_screen_name = \"{}\"", i, n),
|
Some(i) => format!("SELECT * FROM tweet_to_toot WHERE tweet_id = {i} and twitter_screen_name = \"{n}\""),
|
||||||
None => format!("SELECT * FROM tweet_to_toot WHERE twitter_screen_name = \"{}\" ORDER BY tweet_id DESC LIMIT 1", n),
|
None => format!("SELECT * FROM tweet_to_toot WHERE twitter_screen_name = \"{n}\" ORDER BY tweet_id DESC LIMIT 1"),
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut stmt = conn.prepare(&query)?;
|
let mut stmt = conn.prepare(&query)?;
|
||||||
@@ -78,8 +78,7 @@ pub fn migrate_db(d: &str, s: &str) -> Result<(), Box<dyn Error>> {
|
|||||||
&format!(
|
&format!(
|
||||||
"ALTER TABLE tweet_to_toot
|
"ALTER TABLE tweet_to_toot
|
||||||
ADD COLUMN twitter_screen_name TEXT NOT NULL
|
ADD COLUMN twitter_screen_name TEXT NOT NULL
|
||||||
DEFAULT \"{}\"",
|
DEFAULT \"{s}\""
|
||||||
s
|
|
||||||
),
|
),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
@@ -87,7 +86,7 @@ pub fn migrate_db(d: &str, s: &str) -> Result<(), Box<dyn Error>> {
|
|||||||
match res {
|
match res {
|
||||||
Err(e) => match e.to_string().as_str() {
|
Err(e) => match e.to_string().as_str() {
|
||||||
"duplicate column name: twitter_screen_name" => Ok(()),
|
"duplicate column name: twitter_screen_name" => Ok(()),
|
||||||
_ => Err(Box::new(e)),
|
_ => Err(e.into()),
|
||||||
},
|
},
|
||||||
_ => Ok(()),
|
_ => Ok(()),
|
||||||
}
|
}
|
||||||
|
@@ -27,19 +27,31 @@ pub fn get_oauth2_token(config: &TwitterConfig) -> Token {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gets Twitter user timeline
|
/// Gets Twitter user timeline, eliminate responses to others and reverse it
|
||||||
pub async fn get_user_timeline(
|
pub async fn get_user_timeline(
|
||||||
screen_name: &str,
|
screen_name: &str,
|
||||||
token: &Token,
|
token: &Token,
|
||||||
lid: Option<u64>,
|
lid: Option<u64>,
|
||||||
|
page_size: i32,
|
||||||
) -> Result<Vec<Tweet>, Box<dyn Error>> {
|
) -> 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(screen_name.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(page_size)
|
||||||
.older(lid)
|
.older(lid)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(feed.to_vec())
|
let mut feed: Vec<Tweet> = feed
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.filter(|t| match &t.in_reply_to_screen_name {
|
||||||
|
Some(r) => r.to_lowercase() == screen_name.to_lowercase(),
|
||||||
|
None => true,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
feed.reverse();
|
||||||
|
|
||||||
|
Ok(feed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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
|
||||||
|
158
src/util.rs
158
src/util.rs
@@ -1,18 +1,21 @@
|
|||||||
use crate::{twitter::get_tweet_media, ScootalooError};
|
use crate::{twitter::get_tweet_media, ScootalooError};
|
||||||
|
|
||||||
use std::{borrow::Cow, error::Error};
|
use base64::encode;
|
||||||
|
|
||||||
use egg_mode::tweet::Tweet;
|
use egg_mode::tweet::Tweet;
|
||||||
|
use futures::{stream, stream::StreamExt};
|
||||||
use elefren::prelude::*;
|
|
||||||
|
|
||||||
use log::{error, info, warn};
|
use log::{error, info, warn};
|
||||||
|
use megalodon::{
|
||||||
|
entities::UploadMedia::{AsyncAttachment, Attachment},
|
||||||
|
error,
|
||||||
|
mastodon::Mastodon,
|
||||||
|
megalodon::Megalodon,
|
||||||
|
};
|
||||||
use reqwest::Url;
|
use reqwest::Url;
|
||||||
|
use std::error::Error;
|
||||||
use tokio::{
|
use tokio::{
|
||||||
fs::{create_dir_all, remove_file, File},
|
fs::{create_dir_all, remove_file, File},
|
||||||
io::copy,
|
io::copy,
|
||||||
|
time::{sleep, Duration},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Generate associative table between media ids and tweet extended entities
|
/// Generate associative table between media ids and tweet extended entities
|
||||||
@@ -25,63 +28,48 @@ pub async fn generate_media_ids(
|
|||||||
let mut media_ids: Vec<String> = vec![];
|
let mut media_ids: Vec<String> = vec![];
|
||||||
|
|
||||||
if let Some(m) = &tweet.extended_entities {
|
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());
|
info!("{} medias in tweet", m.media.len());
|
||||||
|
|
||||||
for (i, media) in m.media.iter().enumerate() {
|
let medias = m.media.clone();
|
||||||
// attribute media url
|
|
||||||
media_url = media.url.clone();
|
|
||||||
|
|
||||||
// clone everything we need
|
let mut stream = stream::iter(medias)
|
||||||
let cache_path = String::from(cache_path);
|
.map(|media| {
|
||||||
let media = media.clone();
|
// attribute media url
|
||||||
let mastodon = mastodon.clone();
|
media_url = media.url.clone();
|
||||||
|
|
||||||
let task = tokio::task::spawn(async move {
|
// clone everything we need
|
||||||
info!("Start treating {}", media.media_url_https);
|
let cache_path = String::from(cache_path);
|
||||||
// get the tweet embedded media
|
let mastodon = mastodon.clone();
|
||||||
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
|
tokio::task::spawn(async move {
|
||||||
let mastodon_media = mastodon.media(Cow::from(local_tweet_media_path.to_owned()));
|
info!("Start treating {}", media.media_url_https);
|
||||||
// at this point, we can safely erase the original file
|
// get the tweet embedded media
|
||||||
// it doesn’t matter if we can’t remove, cache_media fn is idempotent
|
let local_tweet_media_path = get_tweet_media(&media, &cache_path).await?;
|
||||||
remove_file(&local_tweet_media_path).await.ok();
|
|
||||||
|
|
||||||
let mastodon_media = match mastodon_media {
|
// upload media to Mastodon
|
||||||
Ok(m) => m,
|
let mastodon_media = mastodon
|
||||||
Err(e) => {
|
.upload_media(local_tweet_media_path.to_owned(), None)
|
||||||
return Err(ScootalooError::new(&format!(
|
.await?
|
||||||
"Attachment {} cannot be uploaded to Mastodon Instance: {}",
|
.json();
|
||||||
&local_tweet_media_path, e
|
// at this point, we can safely erase the original file
|
||||||
)))
|
// it doesn’t matter if we can’t remove, cache_media fn is idempotent
|
||||||
}
|
remove_file(&local_tweet_media_path).await.ok();
|
||||||
};
|
|
||||||
|
|
||||||
Ok((i, mastodon_media.id))
|
let id = match mastodon_media {
|
||||||
});
|
Attachment(m) => m.id,
|
||||||
|
AsyncAttachment(m) => wait_until_uploaded(&mastodon, &m.id).await?,
|
||||||
|
};
|
||||||
|
|
||||||
tasks.push(task);
|
Ok::<String, ScootalooError>(id)
|
||||||
}
|
})
|
||||||
|
})
|
||||||
|
.buffered(4); // there are max four medias per tweet and they need to be treated in
|
||||||
|
// order
|
||||||
|
|
||||||
for task in tasks {
|
while let Some(result) = stream.next().await {
|
||||||
match task.await {
|
match result {
|
||||||
// insert the media at the right place
|
Ok(Ok(v)) => media_ids.push(v),
|
||||||
Ok(Ok((i, v))) => media_ids[i] = v,
|
Ok(Err(e)) => warn!("Cannot treat media: {}", e),
|
||||||
Ok(Err(e)) => warn!("{}", e),
|
|
||||||
Err(e) => error!("Something went wrong when joining the main thread: {}", e),
|
Err(e) => error!("Something went wrong when joining the main thread: {}", e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -95,6 +83,45 @@ pub async fn generate_media_ids(
|
|||||||
(media_url, media_ids)
|
(media_url, media_ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wait on uploaded medias when necessary
|
||||||
|
async fn wait_until_uploaded(client: &Mastodon, id: &str) -> Result<String, error::Error> {
|
||||||
|
loop {
|
||||||
|
sleep(Duration::from_secs(1)).await;
|
||||||
|
let res = client.get_media(id.to_string()).await;
|
||||||
|
return match res {
|
||||||
|
Ok(res) => Ok(res.json.id),
|
||||||
|
Err(err) => match err {
|
||||||
|
error::Error::OwnError(ref own_err) => match own_err.kind {
|
||||||
|
error::Kind::HTTPPartialContentError => continue,
|
||||||
|
_ => Err(err),
|
||||||
|
},
|
||||||
|
_ => Err(err),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transforms the media into a base64 equivalent
|
||||||
|
pub async fn base64_media(u: &str) -> Result<String, Box<dyn Error>> {
|
||||||
|
let mut response = reqwest::get(u).await?;
|
||||||
|
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
while let Some(chunk) = response.chunk().await? {
|
||||||
|
copy(&mut &*chunk, &mut buffer).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let content_type = response
|
||||||
|
.headers()
|
||||||
|
.get("content-type")
|
||||||
|
.ok_or_else(|| ScootalooError::new(&format!("Cannot get media content type for {u}")))?
|
||||||
|
.to_str()?;
|
||||||
|
|
||||||
|
let encoded_f = encode(buffer);
|
||||||
|
|
||||||
|
Ok(format!("data:{content_type};base64,{encoded_f}"))
|
||||||
|
}
|
||||||
|
|
||||||
/// 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
|
||||||
@@ -109,19 +136,17 @@ pub async fn cache_media(u: &str, t: &str) -> Result<String, Box<dyn Error>> {
|
|||||||
.path_segments()
|
.path_segments()
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
ScootalooError::new(&format!(
|
ScootalooError::new(&format!(
|
||||||
"Cannot determine the destination filename for {}",
|
"Cannot determine the destination filename for {u}"
|
||||||
u
|
|
||||||
))
|
))
|
||||||
})?
|
})?
|
||||||
.last()
|
.last()
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
ScootalooError::new(&format!(
|
ScootalooError::new(&format!(
|
||||||
"Cannot determine the destination filename for {}",
|
"Cannot determine the destination filename for {u}"
|
||||||
u
|
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let dest_filepath = format!("{}/{}", t, dest_filename);
|
let dest_filepath = format!("{t}/{dest_filename}");
|
||||||
|
|
||||||
let mut dest_file = File::create(&dest_filepath).await?;
|
let mut dest_file = File::create(&dest_filepath).await?;
|
||||||
|
|
||||||
@@ -153,4 +178,17 @@ mod tests {
|
|||||||
|
|
||||||
remove_dir_all(TMP_DIR).unwrap();
|
remove_dir_all(TMP_DIR).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_base64_media() {
|
||||||
|
let img = base64_media(
|
||||||
|
"https://forum.nintendojo.fr/styles/prosilver/theme/images/ndfr_casual.png",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(img.starts_with("data:image/png;base64,"));
|
||||||
|
|
||||||
|
assert!(img.ends_with("="));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
105
tests/config.rs
105
tests/config.rs
@@ -1,4 +1,108 @@
|
|||||||
use scootaloo::parse_toml;
|
use scootaloo::parse_toml;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_alt_services() {
|
||||||
|
let toml = parse_toml("tests/no_test_alt_services.toml");
|
||||||
|
assert_eq!(toml.scootaloo.alternative_services_for, None);
|
||||||
|
|
||||||
|
let toml = parse_toml("tests/test_alt_services.toml");
|
||||||
|
assert_eq!(
|
||||||
|
toml.scootaloo.alternative_services_for,
|
||||||
|
Some(HashMap::from([
|
||||||
|
("tamere.lol".to_string(), "tonpere.mdr".to_string()),
|
||||||
|
("you.pi".to_string(), "you.pla".to_string())
|
||||||
|
]))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_re_display() {
|
||||||
|
let toml = parse_toml("tests/no_show_url_as_display_url_for.toml");
|
||||||
|
assert_eq!(toml.scootaloo.show_url_as_display_url_for, None);
|
||||||
|
|
||||||
|
let toml = parse_toml("tests/show_url_as_display_url_for.toml");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
toml.scootaloo.show_url_as_display_url_for,
|
||||||
|
Some("^(.+)\\.es$".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_page_size() {
|
||||||
|
const DEFAULT_PAGE_SIZE: i32 = 200;
|
||||||
|
let toml = parse_toml("tests/page_size.toml");
|
||||||
|
|
||||||
|
assert_eq!(toml.twitter.page_size, Some(100));
|
||||||
|
|
||||||
|
assert_eq!(toml.mastodon.get("0").unwrap().twitter_page_size, None);
|
||||||
|
|
||||||
|
assert_eq!(toml.mastodon.get("1").unwrap().twitter_page_size, Some(42));
|
||||||
|
|
||||||
|
// this is the exact line that is used inside fn run() to determine the twitter page size
|
||||||
|
// passed to fn get_user_timeline()
|
||||||
|
let page_size_for_0 = toml
|
||||||
|
.mastodon
|
||||||
|
.get("0")
|
||||||
|
.unwrap()
|
||||||
|
.twitter_page_size
|
||||||
|
.unwrap_or_else(|| toml.twitter.page_size.unwrap_or(DEFAULT_PAGE_SIZE));
|
||||||
|
let page_size_for_1 = toml
|
||||||
|
.mastodon
|
||||||
|
.get("1")
|
||||||
|
.unwrap()
|
||||||
|
.twitter_page_size
|
||||||
|
.unwrap_or_else(|| toml.twitter.page_size.unwrap_or(DEFAULT_PAGE_SIZE));
|
||||||
|
|
||||||
|
assert_eq!(page_size_for_0, 100);
|
||||||
|
assert_eq!(page_size_for_1, 42);
|
||||||
|
|
||||||
|
let toml = parse_toml("tests/no_page_size.toml");
|
||||||
|
|
||||||
|
assert_eq!(toml.twitter.page_size, None);
|
||||||
|
assert_eq!(toml.mastodon.get("0").unwrap().twitter_page_size, None);
|
||||||
|
|
||||||
|
// and same here
|
||||||
|
let page_size_for_0 = toml
|
||||||
|
.mastodon
|
||||||
|
.get("0")
|
||||||
|
.unwrap()
|
||||||
|
.twitter_page_size
|
||||||
|
.unwrap_or_else(|| toml.twitter.page_size.unwrap_or(DEFAULT_PAGE_SIZE));
|
||||||
|
|
||||||
|
assert_eq!(page_size_for_0, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_good_toml_rate_limit() {
|
||||||
|
let parse_good_toml = parse_toml("tests/good_test_rate_limit.toml");
|
||||||
|
|
||||||
|
assert_eq!(parse_good_toml.scootaloo.rate_limit, Some(69 as usize));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_good_toml_mastodon_screen_name() {
|
||||||
|
let parse_good_toml = parse_toml("tests/good_test_mastodon_screen_name.toml");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
parse_good_toml
|
||||||
|
.mastodon
|
||||||
|
.get("0")
|
||||||
|
.unwrap()
|
||||||
|
.mastodon_screen_name,
|
||||||
|
Some("tarace".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
parse_good_toml
|
||||||
|
.mastodon
|
||||||
|
.get("1")
|
||||||
|
.unwrap()
|
||||||
|
.mastodon_screen_name,
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_good_toml() {
|
fn test_parse_good_toml() {
|
||||||
@@ -9,6 +113,7 @@ fn test_parse_good_toml() {
|
|||||||
"/var/random/scootaloo.sqlite"
|
"/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.scootaloo.rate_limit, None);
|
||||||
|
|
||||||
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");
|
||||||
|
28
tests/good_test_mastodon_screen_name.toml
Normal file
28
tests/good_test_mastodon_screen_name.toml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
[scootaloo]
|
||||||
|
|
||||||
|
db_path="/var/random/scootaloo.sqlite"
|
||||||
|
cache_path="/tmp/scootaloo"
|
||||||
|
|
||||||
|
[twitter]
|
||||||
|
consumer_key="rand consumer key"
|
||||||
|
consumer_secret="secret"
|
||||||
|
access_key="rand access key"
|
||||||
|
access_secret="super secret"
|
||||||
|
|
||||||
|
[mastodon]
|
||||||
|
[mastodon.0]
|
||||||
|
twitter_screen_name="tamerelol"
|
||||||
|
mastodon_screen_name="tarace"
|
||||||
|
base = "https://m.nintendojo.fr"
|
||||||
|
client_id = "rand client id"
|
||||||
|
client_secret = "secret"
|
||||||
|
redirect = "urn:ietf:wg:oauth:2.0:oob"
|
||||||
|
token = "super secret"
|
||||||
|
|
||||||
|
[mastodon.1]
|
||||||
|
twitter_screen_name="tamerelol"
|
||||||
|
base = "https://m.nintendojo.fr"
|
||||||
|
client_id = "rand client id"
|
||||||
|
client_secret = "secret"
|
||||||
|
redirect = "urn:ietf:wg:oauth:2.0:oob"
|
||||||
|
token = "super secret"
|
20
tests/good_test_rate_limit.toml
Normal file
20
tests/good_test_rate_limit.toml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
[scootaloo]
|
||||||
|
|
||||||
|
db_path="/var/random/scootaloo.sqlite"
|
||||||
|
cache_path="/tmp/scootaloo"
|
||||||
|
rate_limit=69
|
||||||
|
|
||||||
|
[twitter]
|
||||||
|
consumer_key="rand consumer key"
|
||||||
|
consumer_secret="secret"
|
||||||
|
access_key="rand access key"
|
||||||
|
access_secret="super secret"
|
||||||
|
|
||||||
|
[mastodon]
|
||||||
|
[mastodon.tamerelol]
|
||||||
|
twitter_screen_name="tamerelol"
|
||||||
|
base = "https://m.nintendojo.fr"
|
||||||
|
client_id = "rand client id"
|
||||||
|
client_secret = "secret"
|
||||||
|
redirect = "urn:ietf:wg:oauth:2.0:oob"
|
||||||
|
token = "super secret"
|
19
tests/no_page_size.toml
Normal file
19
tests/no_page_size.toml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
[scootaloo]
|
||||||
|
|
||||||
|
db_path="/var/random/scootaloo.sqlite"
|
||||||
|
cache_path="/tmp/scootaloo"
|
||||||
|
|
||||||
|
[twitter]
|
||||||
|
consumer_key="rand consumer key"
|
||||||
|
consumer_secret="secret"
|
||||||
|
access_key="rand access key"
|
||||||
|
access_secret="super secret"
|
||||||
|
|
||||||
|
[mastodon]
|
||||||
|
[mastodon.0]
|
||||||
|
twitter_screen_name="tamerelol"
|
||||||
|
base = "https://m.nintendojo.fr"
|
||||||
|
client_id = "rand client id"
|
||||||
|
client_secret = "secret"
|
||||||
|
redirect = "urn:ietf:wg:oauth:2.0:oob"
|
||||||
|
token = "super secret"
|
19
tests/no_show_url_as_display_url_for.toml
Normal file
19
tests/no_show_url_as_display_url_for.toml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
[scootaloo]
|
||||||
|
|
||||||
|
db_path="/var/random/scootaloo.sqlite"
|
||||||
|
cache_path="/tmp/scootaloo"
|
||||||
|
|
||||||
|
[twitter]
|
||||||
|
consumer_key="rand consumer key"
|
||||||
|
consumer_secret="secret"
|
||||||
|
access_key="rand access key"
|
||||||
|
access_secret="super secret"
|
||||||
|
|
||||||
|
[mastodon]
|
||||||
|
[mastodon.tamerelol]
|
||||||
|
twitter_screen_name="tamerelol"
|
||||||
|
base = "https://m.nintendojo.fr"
|
||||||
|
client_id = "rand client id"
|
||||||
|
client_secret = "secret"
|
||||||
|
redirect = "urn:ietf:wg:oauth:2.0:oob"
|
||||||
|
token = "super secret"
|
19
tests/no_test_alt_services.toml
Normal file
19
tests/no_test_alt_services.toml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
[scootaloo]
|
||||||
|
|
||||||
|
db_path="/var/random/scootaloo.sqlite"
|
||||||
|
cache_path="/tmp/scootaloo"
|
||||||
|
|
||||||
|
[twitter]
|
||||||
|
consumer_key="rand consumer key"
|
||||||
|
consumer_secret="secret"
|
||||||
|
access_key="rand access key"
|
||||||
|
access_secret="super secret"
|
||||||
|
|
||||||
|
[mastodon]
|
||||||
|
[mastodon.tamerelol]
|
||||||
|
twitter_screen_name="tamerelol"
|
||||||
|
base = "https://m.nintendojo.fr"
|
||||||
|
client_id = "rand client id"
|
||||||
|
client_secret = "secret"
|
||||||
|
redirect = "urn:ietf:wg:oauth:2.0:oob"
|
||||||
|
token = "super secret"
|
29
tests/page_size.toml
Normal file
29
tests/page_size.toml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
[scootaloo]
|
||||||
|
|
||||||
|
db_path="/var/random/scootaloo.sqlite"
|
||||||
|
cache_path="/tmp/scootaloo"
|
||||||
|
|
||||||
|
[twitter]
|
||||||
|
consumer_key="rand consumer key"
|
||||||
|
consumer_secret="secret"
|
||||||
|
access_key="rand access key"
|
||||||
|
access_secret="super secret"
|
||||||
|
page_size=100
|
||||||
|
|
||||||
|
[mastodon]
|
||||||
|
[mastodon.0]
|
||||||
|
twitter_screen_name="tamerelol"
|
||||||
|
base = "https://m.nintendojo.fr"
|
||||||
|
client_id = "rand client id"
|
||||||
|
client_secret = "secret"
|
||||||
|
redirect = "urn:ietf:wg:oauth:2.0:oob"
|
||||||
|
token = "super secret"
|
||||||
|
|
||||||
|
[mastodon.1]
|
||||||
|
twitter_screen_name="tonperemdr"
|
||||||
|
twitter_page_size=42
|
||||||
|
base = "https://m.nintendojo.fr"
|
||||||
|
client_id = "rand client id"
|
||||||
|
client_secret = "secret"
|
||||||
|
redirect = "urn:ietf:wg:oauth:2.0:oob"
|
||||||
|
token = "super secret"
|
20
tests/show_url_as_display_url_for.toml
Normal file
20
tests/show_url_as_display_url_for.toml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
[scootaloo]
|
||||||
|
|
||||||
|
db_path="/var/random/scootaloo.sqlite"
|
||||||
|
cache_path="/tmp/scootaloo"
|
||||||
|
show_url_as_display_url_for = "^(.+)\\.es$"
|
||||||
|
|
||||||
|
[twitter]
|
||||||
|
consumer_key="rand consumer key"
|
||||||
|
consumer_secret="secret"
|
||||||
|
access_key="rand access key"
|
||||||
|
access_secret="super secret"
|
||||||
|
|
||||||
|
[mastodon]
|
||||||
|
[mastodon.tamerelol]
|
||||||
|
twitter_screen_name="tamerelol"
|
||||||
|
base = "https://m.nintendojo.fr"
|
||||||
|
client_id = "rand client id"
|
||||||
|
client_secret = "secret"
|
||||||
|
redirect = "urn:ietf:wg:oauth:2.0:oob"
|
||||||
|
token = "super secret"
|
22
tests/test_alt_services.toml
Normal file
22
tests/test_alt_services.toml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
[scootaloo]
|
||||||
|
|
||||||
|
db_path="/var/random/scootaloo.sqlite"
|
||||||
|
cache_path="/tmp/scootaloo"
|
||||||
|
[scootaloo.alternative_services_for]
|
||||||
|
"tamere.lol" = "tonpere.mdr"
|
||||||
|
"you.pi" = "you.pla"
|
||||||
|
|
||||||
|
[twitter]
|
||||||
|
consumer_key="rand consumer key"
|
||||||
|
consumer_secret="secret"
|
||||||
|
access_key="rand access key"
|
||||||
|
access_secret="super secret"
|
||||||
|
|
||||||
|
[mastodon]
|
||||||
|
[mastodon.tamerelol]
|
||||||
|
twitter_screen_name="tamerelol"
|
||||||
|
base = "https://m.nintendojo.fr"
|
||||||
|
client_id = "rand client id"
|
||||||
|
client_secret = "secret"
|
||||||
|
redirect = "urn:ietf:wg:oauth:2.0:oob"
|
||||||
|
token = "super secret"
|
Reference in New Issue
Block a user