49 Commits

Author SHA1 Message Date
VC
3413f49d08 Update README.md 2023-07-13 09:02:57 +00:00
VC
d4fbccc69b Merge branch 'youpi' into 'master'
Update file README.md

See merge request veretcle/scootaloo!57
2023-07-13 08:57:08 +00:00
VC
058a07865d Update file README.md 2023-07-13 08:53:54 +00:00
VC
c6a29d1d7d Merge branch 'doc_correct' into 'master'
doc: update example regexp

See merge request veretcle/scootaloo!55
2023-02-10 08:53:08 +00:00
VC
3692d6e51f doc: update example regexp 2023-02-10 09:40:42 +01:00
VC
5fe57f189a Merge branch 'feat_wait_for_upload' into 'master'
Feat wait for upload

See merge request veretcle/scootaloo!54
2023-02-09 14:51:14 +00:00
VC
83c398cebf feat: wait 1 full sec between loop when uploading big media 2023-02-09 15:19:22 +01:00
VC
8f567ed6b4 chore: bump version 2023-02-09 15:18:12 +01:00
VC
d7431862ba Merge branch 'fix_copy_lang' into 'master'
Fix copy lang

See merge request veretcle/scootaloo!53
2023-02-09 14:12:25 +00:00
VC
f3b13eb62f refactor: conforms to clippy 1.67 recommandations 2023-02-09 11:32:22 +01:00
VC
6b68c8e299 fix: no need for defaults with clap v4 2023-02-09 11:31:58 +01:00
VC
0bb5eabdac fix: copy the original lang from Twitter to Mastodon 2023-02-09 10:58:34 +01:00
VC
3d44bbfb86 refactor: remove isolang, bump version 2023-02-09 10:58:12 +01:00
VC
9a03c7681b Merge branch 'fix_attachment' into 'master'
Fix attachment

See merge request veretcle/scootaloo!52
2023-01-09 11:51:41 +00:00
VC
a8a8f8c13f fix: wait until media is uploaded 2023-01-09 12:45:38 +01:00
VC
90a9df220a chore: bump version to 1.1.4, megalodon to 0.3.6 2023-01-09 12:45:18 +01:00
VC
6218c59ce5 Merge branch 'feat_fields' into 'master'
Feat fields

See merge request veretcle/scootaloo!51
2022-12-27 14:46:21 +00:00
VC
6ffcbfc89a feat: add links in fields attribute 2022-12-27 15:31:12 +01:00
VC
3fdd81df50 Merge branch 'feat_links' into 'master'
feat: fields_attributes and note inside copyprofile

See merge request veretcle/scootaloo!50
2022-12-12 14:35:42 +00:00
VC
90f47079d9 fix: fields_attribute is busted so 🤷 2022-12-12 15:25:12 +01:00
VC
88b73f4bc5 chore: bump version 2022-12-12 15:25:12 +01:00
VC
87797c7ab0 feat: note inside copyprofile 2022-12-12 15:25:06 +01:00
VC
3645728ddf Merge branch 'fix_copy_profile' into 'master'
Fix copy profile

See merge request veretcle/scootaloo!49
2022-12-04 08:04:45 +00:00
VC
69648728d7 chore: bump version 2022-12-03 17:27:51 +01:00
VC
6af1e4c55a fix: encode display_name as utf16 2022-12-03 17:27:18 +01:00
VC
8d55ea69a2 Merge branch '11-copy-profile-from-twitter-to-mastodon' into 'master'
Copy profile from Twitter to Mastodon

Closes #11

See merge request veretcle/scootaloo!48
2022-12-02 08:53:50 +00:00
VC
b5b0a63f67 feat: further optimize executable size 2022-12-02 09:14:30 +01:00
VC
0f5ab4158c feat: add copyprofile subcommand 2022-12-02 09:14:26 +01:00
VC
25f98581a5 Merge branch '12-remove-elefren-deprecated-in-favor-of-megalodon-rs' into 'master'
Remove elefren (deprecated) in favor of megalodon-rs

Closes #12

See merge request veretcle/scootaloo!47
2022-11-30 09:09:42 +00:00
VC
7f42c9d01a feat: use megalodon instead of elefren 2022-11-29 21:23:30 +01:00
VC
19f75a9e76 chore: bump version 2022-11-29 18:19:47 +01:00
VC
6e23e0ab14 Merge branch 'cargo_lock' into 'master'
chore: bump version

See merge request veretcle/scootaloo!46
2022-11-25 20:00:55 +00:00
VC
c3862fea55 chore: bump version 2022-11-25 21:00:35 +01:00
VC
c0ae9dc52f Merge branch '10-implement-smart-quoting' into 'master'
feat: when quoting another Scootaloo instance user, try to find the...

Closes #10

See merge request veretcle/scootaloo!45
2022-11-25 19:50:41 +00:00
VC
2ae87b2767 feat: when quoting another Scootaloo instance user, try to find the corresponding toot to replace it inside the url entities 2022-11-25 19:57:04 +01:00
VC
0399623cfa chore: bump version 2022-11-25 19:57:02 +01:00
VC
895c41c75f chore: update crate + bump version 2022-11-25 09:53:01 +01:00
VC
63830be0d5 Merge branch '9-refactor-text-status-building' into 'master'
Refactor text status building

Closes #9

See merge request veretcle/scootaloo!44
2022-11-24 22:14:55 +00:00
VC
5633bf9187 refactor: twitter threading no longer on top 2022-11-24 23:10:30 +01:00
VC
f42aa8cbb6 refactor: build status text more progressively 2022-11-24 15:41:50 +01:00
VC
1132f41b9e chore: bump version 2022-11-24 15:25:17 +01:00
VC
70f8c14e99 doc: regexp + alt services 2022-11-24 08:00:46 +01:00
VC
faab50d1ea feat: main logic for regex + url filtering 2022-11-24 08:00:45 +01:00
VC
9cafa2bf07 test: add tests for scootaloo alt services + regexp 2022-11-24 08:00:43 +01:00
VC
9227850c99 feat: add necessary changes to decode_urls fn 2022-11-23 13:09:00 +01:00
VC
64d72ea69d chore: bump version + add regex 2022-11-23 09:00:44 +01:00
VC
9dd6ab8370 Merge branch 'fix_smart_mentions' into 'master'
Fix smart mentions

See merge request veretcle/scootaloo!37
2022-11-21 20:33:32 +00:00
VC
4679578101 chore: bump version 2022-11-21 21:28:09 +01:00
VC
2501d5990f fix: typo in the scootaloo_mentions var 2022-11-21 21:27:40 +01:00
15 changed files with 1425 additions and 1755 deletions

1922
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +1,22 @@
[package] [package]
name = "scootaloo" name = "scootaloo"
version = "0.10.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 = "^4" clap = "^4"
egg-mode = "^0.16" egg-mode = "^0.16"
rusqlite = "^0.27" rusqlite = "^0.27"
tokio = { version = "^1", features = ["full"]} tokio = { version = "^1", features = ["rt"]}
futures = "^0.3" futures = "^0.3"
elefren = "^0.22" 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

View File

@@ -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, Im deeply sorry about that. It worked pretty great for what it did with a level of quality Im 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:
@@ -19,6 +25,19 @@ First up, create a configuration file (default path is `/usr/local/etc/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 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), theyre 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 youll 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)
@@ -70,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

View File

@@ -19,7 +19,7 @@ pub struct TwitterConfig {
pub page_size: Option<i32>, 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 mastodon_screen_name: Option<String>,
@@ -36,15 +36,17 @@ 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 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
} }

View File

@@ -5,7 +5,7 @@ use std::{
fmt::{Display, Formatter, Result}, fmt::{Display, Formatter, Result},
}; };
use elefren::Error as elefrenError; use megalodon::error::Error as megalodonError;
#[derive(Debug)] #[derive(Debug)]
pub struct ScootalooError { pub struct ScootalooError {
@@ -30,12 +30,12 @@ impl Display for ScootalooError {
impl From<Box<dyn Error>> for ScootalooError { impl From<Box<dyn Error>> for ScootalooError {
fn from(error: Box<dyn Error>) -> Self { fn from(error: Box<dyn Error>) -> Self {
ScootalooError::new(&format!("Error in a subset crate: {}", error)) ScootalooError::new(&format!("Error in a subset crate: {error}"))
} }
} }
impl From<elefrenError> for ScootalooError { impl From<megalodonError> for ScootalooError {
fn from(error: elefrenError) -> Self { fn from(error: megalodonError) -> Self {
ScootalooError::new(&format!("Error in elefren crate: {}", error)) ScootalooError::new(&format!("Error in megalodon crate: {error}"))
} }
} }

View File

@@ -7,23 +7,27 @@ 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, Language};
use futures::StreamExt; 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::{collections::HashMap, sync::Arc}; use std::sync::Arc;
use tokio::{spawn, sync::Mutex}; use tokio::{spawn, sync::Mutex};
const DEFAULT_RATE_LIMIT: usize = 4; const DEFAULT_RATE_LIMIT: usize = 4;
@@ -42,21 +46,15 @@ pub async fn run(config: Config) {
}), }),
)); ));
let scootaloo_mentions: HashMap<String, String> = config let global_mastodon_config = Arc::new(Mutex::new(config.mastodon.clone()));
.mastodon
.values() let display_url_re = config
.filter(|s| s.mastodon_screen_name.is_some()) .scootaloo
.map(|s| { .show_url_as_display_url_for
( .as_ref()
format!("@{}", s.twitter_screen_name), .map(|r|
format!( // we want to panic in case the RE is not valid
"{}@{}", Regex::new(r).unwrap());
s.mastodon_screen_name.as_ref().unwrap(),
s.base.split('/').last().unwrap()
),
)
})
.collect();
let mut stream = futures::stream::iter(config.mastodon.into_values()) let mut stream = futures::stream::iter(config.mastodon.into_values())
.map(|mastodon_config| { .map(|mastodon_config| {
@@ -67,9 +65,11 @@ pub async fn run(config: Config) {
// create temporary value for each task // create temporary value for each task
let scootaloo_cache_path = config.scootaloo.cache_path.clone(); let scootaloo_cache_path = config.scootaloo.cache_path.clone();
let scootaloo_mentions = scootaloo_mentions.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 token = get_oauth2_token(&config.twitter);
let task_conn = conn.clone(); let task_conn = conn.clone();
let global_mastodon_config = global_mastodon_config.clone();
spawn(async move { spawn(async move {
info!("Starting treating {}", &mastodon_config.twitter_screen_name); info!("Starting treating {}", &mastodon_config.twitter_screen_name);
@@ -94,21 +94,71 @@ pub async fn run(config: Config) {
for tweet in &feed { for tweet in &feed {
info!("Treating Tweet {} inside feed", tweet.id); info!("Treating Tweet {} inside feed", tweet.id);
let lconn = task_conn.lock().await; // basic toot text
// initiate the toot_reply_id var and retrieve the corresponding toot_id let mut status_text = tweet.text.clone();
let toot_reply_id: Option<String> = tweet.in_reply_to_user_id.and_then(|_| {
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 // add mentions and smart mentions
let mut status_text = build_basic_status(tweet, &scootaloo_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 theyre 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 wont 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 // building associative media list
let (media_url, status_medias) = let (media_url, status_medias) =
@@ -116,30 +166,53 @@ pub async fn run(config: Config) {
status_text = status_text.replace(&media_url, ""); status_text = status_text.replace(&media_url, "");
// now that the text wont be altered anymore, we can safely remove HTML
// entities
status_text = decode_html_entities(&status_text).to_string();
info!("Building corresponding Mastodon status"); info!("Building corresponding Mastodon status");
let mut status_builder = StatusBuilder::new(); 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,
};
status_builder.status(&status_text).media_ids(status_medias); if !status_medias.is_empty() {
post_status.media_ids = Some(status_medias);
}
// theard if necessary // thread if necessary
if let Some(i) = toot_reply_id { if tweet.in_reply_to_user_id.is_some() {
status_builder.in_reply_to(&i); 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 // language if any
if let Some(l) = &tweet.lang { if let Some(l) = &tweet.lang {
if let Some(r) = Language::from_639_1(l) { post_status.language = Some(l.to_string());
status_builder.language(r);
}
} }
// can be activated for test purposes // can be activated for test purposes
// status_builder.visibility(elefren::status_builder::Visibility::Private); // post_status.visibility = Some(megalodon::entities::StatusVisibility::Direct);
let status = status_builder.build()?; let published_status = mastodon
.post_status(status_text, Some(&post_status))
let published_status = mastodon.new_status(status)?; .await?
.json();
// this will return if it cannot publish the status preventing the last_tweet from // this will return if it cannot publish the status preventing the last_tweet from
// being written into db // being written into db
@@ -162,8 +235,77 @@ pub async fn run(config: Config) {
// launch and wait for every handle // launch and wait for every handle
while let Some(result) = stream.next().await { while let Some(result) = stream.next().await {
match result { match result {
Ok(Err(e)) => eprintln!("Error within thread: {}", e), Ok(Err(e)) => eprintln!("Error within thread: {e}"),
Err(e) => eprintln!("Error with 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("Cant 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}"),
_ => (), _ => (),
} }
} }

View File

@@ -1,4 +1,4 @@
use clap::{Arg, Command}; use clap::{Arg, ArgAction, Command};
use log::LevelFilter; use log::LevelFilter;
use scootaloo::*; use scootaloo::*;
use simple_logger::SimpleLogger; use simple_logger::SimpleLogger;
@@ -63,10 +63,7 @@ fn main() {
.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_CONFIG_PATH
))
.default_value(DEFAULT_CONFIG_PATH) .default_value(DEFAULT_CONFIG_PATH)
.num_args(1) .num_args(1)
.display_order(1), .display_order(1),
@@ -81,18 +78,49 @@ fn main() {
.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")
.default_value(DEFAULT_CONFIG_PATH) .default_value(DEFAULT_CONFIG_PATH)
.num_args(1) .num_args(1)
.display_order(1), .display_order(1),
) )
.arg( .arg(
Arg::new("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)")
.num_args(1) .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();
@@ -123,6 +151,22 @@ fn main() {
.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;
}
_ => (), _ => (),
} }

View File

@@ -2,98 +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::Read, scopes::Scopes, scopes::Write}; 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, mentions: &HashMap<String, String>) -> 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| {
.into_iter() u.urls.first().and_then(|v| {
.chain(mentions.to_owned()) v.expanded_url.as_ref().map(|e| {
.collect::<HashMap<String, String>>() vec![CredentialsFieldAttribute {
{ name: v.display_url.to_string(),
toot = toot.replace(&decoded_mention.0, &decoded_mention.1); value: e.to_string(),
} }]
})
decode_html_entities(&toot).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(Write::Accounts)
.and(Scopes::write(Write::Media))
.and(Scopes::write(Write::Statuses))
.and(Scopes::read(Read::Accounts)),
)
.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();
@@ -101,27 +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 let current_account = mastodon
.verify_credentials() .verify_account_credentials()
.expect("Unable to access account information!"); .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 = \"{}\" mastodon_screen_name = "{}"
{}", base = "{}"
client_id = "{}"
client_secret = "{}"
redirect = "{}"
token = "{}""#,
screen_name.to_lowercase(), screen_name.to_lowercase(),
screen_name, screen_name,
current_account.username, current_account.username,
toml host,
app_data.client_id,
app_data.client_secret,
app_data.redirect_uri,
token_data.access_token,
); );
} }
@@ -129,126 +240,343 @@ mastodon_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),
name: "Nintendo France".to_string(),
screen_name: "NintendoFrance".to_string(),
},
MentionEntity {
id: 999999999,
range: (80, 95),
name: "Willy Wonka".to_string(),
screen_name: "WillyWonka".to_string(),
},
],
media: None,
},
extended_entities: None,
favorite_count: 0,
favorited: None,
filter_level: None,
id: 1491541246984306693,
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 &amp; 2 sur le NES/SNES online !\nDispo maintenant. cc @NintendoFrance @WillyWonka https://t.co/zXw0FfX2Nt".to_string(),
truncated: false,
user: None,
withheld_copyright: false,
withheld_in_countries: None,
withheld_scope: None,
};
let s: HashMap<String, String> = HashMap::from([( let expected_urls = HashMap::from([
"@WillyWonka".to_string(), (
"@WillyWonka@chocolatefactory.org".to_string(), "https://t.co/perdudeouf".to_string(),
"https://www.perdu.com".to_string(),
),
(
"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(),
},
MentionEntity {
id: 6789,
range: (1, 3),
name: "TONPERE".to_string(),
screen_name: "tonpere".to_string(),
},
];
let mut toot = ":kikoo: @tamerelol @tonpere !".to_string();
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(),
}),
)]); )]);
let t_out = build_basic_status(&t, &s); twitter_mentions(&mut toot, &mention_entities, &masto_config);
assert_eq!(&t_out, "Mother 1 & 2 sur le NES/SNES online !\nDispo maintenant. cc @NintendoFrance@twitter.com @WillyWonka@chocolatefactory.org https://www.youtube.com/watch?v=w5TrSaoYmZ8"); 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);
} }
} }

View File

@@ -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
), ),
[], [],
); );

View File

@@ -1,22 +1,23 @@
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},
}; };
use futures::{stream, stream::StreamExt};
/// Generate associative table between media ids and tweet extended entities /// Generate associative table between media ids and tweet extended entities
pub async fn generate_media_ids( pub async fn generate_media_ids(
tweet: &Tweet, tweet: &Tweet,
@@ -46,13 +47,20 @@ pub async fn generate_media_ids(
let local_tweet_media_path = get_tweet_media(&media, &cache_path).await?; let local_tweet_media_path = get_tweet_media(&media, &cache_path).await?;
// upload media to Mastodon // upload media to Mastodon
let mastodon_media = let mastodon_media = mastodon
mastodon.media(Cow::from(local_tweet_media_path.to_owned()))?; .upload_media(local_tweet_media_path.to_owned(), None)
.await?
.json();
// at this point, we can safely erase the original file // at this point, we can safely erase the original file
// it doesnt matter if we cant remove, cache_media fn is idempotent // it doesnt matter if we cant remove, cache_media fn is idempotent
remove_file(&local_tweet_media_path).await.ok(); remove_file(&local_tweet_media_path).await.ok();
Ok::<String, ScootalooError>(mastodon_media.id) let id = match mastodon_media {
Attachment(m) => m.id,
AsyncAttachment(m) => wait_until_uploaded(&mastodon, &m.id).await?,
};
Ok::<String, ScootalooError>(id)
}) })
}) })
.buffered(4); // there are max four medias per tweet and they need to be treated in .buffered(4); // there are max four medias per tweet and they need to be treated in
@@ -75,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
@@ -89,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?;
@@ -133,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("="));
}
} }

View File

@@ -1,4 +1,33 @@
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] #[test]
fn test_page_size() { fn test_page_size() {

View 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"

View 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"

View 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"

View 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"