mirror of
https://framagit.org/veretcle/oolatoocs.git
synced 2025-07-20 20:41:17 +02:00
Compare commits
38 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
9f9cf52722 | ||
![]() |
e0d3667fb9 | ||
![]() |
ac8af5ce95 | ||
![]() |
f7e2aafa7b | ||
![]() |
6e9bb6b42c | ||
![]() |
88edb1b2e1 | ||
![]() |
bf9d27df61 | ||
![]() |
496dde60d6 | ||
![]() |
567dfae7ab | ||
![]() |
eeaea52e80 | ||
![]() |
4a0dbb06af | ||
![]() |
5c17ea6989 | ||
![]() |
8674048e8d | ||
![]() |
378d973697 | ||
![]() |
2cb732efed | ||
![]() |
5d685b5748 | ||
![]() |
66664ff621 | ||
![]() |
fd84730bdc | ||
![]() |
692f4ff040 | ||
![]() |
3397416a93 | ||
![]() |
f782987991 | ||
![]() |
26788f9d37 | ||
![]() |
ca9b388a50 | ||
![]() |
42958e0a92 | ||
![]() |
77be17e7bf | ||
![]() |
bd9fd27fd1 | ||
![]() |
3e6cae6136 | ||
![]() |
f10baa3eb2 | ||
![]() |
c113c1472a | ||
![]() |
cdf7dc70c1 | ||
![]() |
b1aed34f3c | ||
![]() |
e8bde4c779 | ||
![]() |
80946ac131 | ||
![]() |
87b0567b59 | ||
![]() |
b6f87e829f | ||
![]() |
6fccbf8d16 | ||
![]() |
1fdea7f69d | ||
![]() |
b73d6340c9 |
2021
Cargo.lock
generated
2021
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
17
Cargo.toml
17
Cargo.toml
@@ -1,23 +1,28 @@
|
||||
[package]
|
||||
name = "oolatoocs"
|
||||
version = "1.0.0"
|
||||
version = "3.0.1"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
rand = "^0.8" # /!\ to be removed
|
||||
chrono = "^0.4"
|
||||
clap = "^4"
|
||||
dissolve = "0.2.2"
|
||||
env_logger = "^0.10"
|
||||
futures = "^0.3"
|
||||
html-escape = "^0.2"
|
||||
log = "^0.4"
|
||||
megalodon = "^0.11"
|
||||
megalodon = "^0.13"
|
||||
oauth1-request = "^0.6"
|
||||
regex = "1.10.2"
|
||||
reqwest = { version = "0.11.22", features = ["json", "stream", "multipart"] }
|
||||
rusqlite = "^0.27"
|
||||
regex = "^1.10"
|
||||
reqwest = { version = "^0.11", features = ["json", "stream", "multipart"] }
|
||||
rusqlite = { version = "^0.30", features = ["chrono"] }
|
||||
serde = { version = "^1.0", features = ["derive"] }
|
||||
tokio = { version = "^1.33", features = ["rt-multi-thread", "macros", "time"] }
|
||||
toml = "^0.8"
|
||||
bsky-sdk = "^0.1"
|
||||
atrium-api = "^0.24"
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
|
89
README.md
Normal file
89
README.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# oolatoocs, a Mastodon to Twitter bot
|
||||
|
||||
So what is it? Originally, I wrote, with some help, [Scootaloo](https://framagit.org/veretcle/scootaloo/) which was a Twitter to Mastodon Bot to help the [writers at NintendojoFR](https://www.nintendojo.fr) not to worry about Mastodon: the vast majority of writers were posting to Twitter, the bot scooped everything and arranged it properly for Mastodon and everything was fine and dandy. It was also used, in an altered beefed-up version, for [Nupes.social](https://nupes.social) to make the tweets from the NUPES political alliance on Twitter, more easily accessible in Mastodon.
|
||||
|
||||
But then Elon came, and we couldn’t read data from Twitter anymore. So we had to rely on copy/pasting things from one to another, which is not fun nor efficient.
|
||||
|
||||
Hence `oolatoocs`, which takes a Mastodon Timeline and reposts it to Twitter as properly as possible. And since Bluesky seems to be hype right now, it also incorporates Bluesky support since v3.0.0.
|
||||
|
||||
Bluesky support is mandatory for now on: you can’t have Twitter or Bluesky, you must have both. I might change this behaviour in a near future, especially when I will inevitably have to drop support for Twitter. If you just want Twitter support, just stick with v2.4.x release, it’ll get the job done exactly as the newer version for now.
|
||||
|
||||
If you don’t want Twitter support, open an issue and I will get motivated to comply (maybe…).
|
||||
|
||||
# Remarkable features
|
||||
|
||||
What it can do:
|
||||
* Reproduces the Toot content into the Tweet/Record;
|
||||
* Cuts (poorly) the Toot in half in it’s too long for Twitter/Bluesky and thread it (this is cut using a word count, not the best method, but it gets the job done);
|
||||
* Reuploads images/gifs/videos from Mastodon to Twitter/Bluesky
|
||||
* Can reproduce threads from Mastodon to Twitter/Bluesky
|
||||
* Can reproduce poll from Mastodon to Twitter/Bluesky
|
||||
* Can prevent a Toot from being tweeted/recorded to Bluesky by using the #NoTweet (case-insensitive) hashtag in Mastodon
|
||||
|
||||
# Configuration file
|
||||
|
||||
The configuration is relatively easy to follow:
|
||||
|
||||
```toml
|
||||
[oolatoocs]
|
||||
db_path = "/var/lib/oolatoocs/db.sqlite3" # the path to the DB where toot/tweet are stored
|
||||
|
||||
[mastodon] # This part can be generated, see below
|
||||
base = "https://m.nintendojo.fr"
|
||||
client_id = "<REDACTED>"
|
||||
client_secret = "<REDACTED>"
|
||||
redirect = "urn:ietf:wg:oauth:2.0:oob"
|
||||
token = "<REDACTED>"
|
||||
|
||||
[twitter] # you’ll have to get this part from Twitter, this can be done via https://developer.twitter.com/en
|
||||
consumer_key = "<REDACTED>"
|
||||
consumer_secret = "<REDACTED>"
|
||||
oauth_token = "<REDACTED>"
|
||||
oauth_token_secret = "<REDACTED>"
|
||||
|
||||
[bluesky]
|
||||
handle = "nintendojofr.bsky.social"
|
||||
password = "<REDACTED>"
|
||||
```
|
||||
|
||||
## How to generate the Mastodon keys?
|
||||
|
||||
Just run:
|
||||
|
||||
```bash
|
||||
oolatoocs register --host https://<your-instance>
|
||||
```
|
||||
|
||||
And follow the instructions.
|
||||
|
||||
## How to generate the Twitter part?
|
||||
|
||||
You’ll need to generate a key. This is a real pain in the ass, but you can use [this script](https://github.com/twitterdev/Twitter-API-v2-sample-code/blob/main/Manage-Tweets/create_tweet.py), modify it and run it to recover you key.
|
||||
|
||||
Will I some day make a subcommand to generate it? Maybe…
|
||||
|
||||
## How to generate the Bluesky part?
|
||||
|
||||
You’ll need your handle and password. I strongly recommend a dedicated application password.
|
||||
|
||||
# How to run
|
||||
|
||||
First of all, the `--help`:
|
||||
|
||||
```bash
|
||||
A Mastodon to Twitter Bot
|
||||
|
||||
Usage: oolatoocs [OPTIONS] [COMMAND]
|
||||
|
||||
Commands:
|
||||
init Command to init the DB
|
||||
register Command to register to Mastodon Instance
|
||||
help Print this message or the help of the given subcommand(s)
|
||||
|
||||
Options:
|
||||
-c, --config <CONFIG_FILE> TOML config file for oolatoocs [default: /usr/local/etc/oolatoocs.toml]
|
||||
-h, --help Print help
|
||||
-V, --version Print version
|
||||
```
|
||||
|
||||
Ideally, you’ll put it an cron (from a non-root user), with the default path for config file and let it do its job. Yeah, that’s it.
|
212
src/bsky.rs
Normal file
212
src/bsky.rs
Normal file
@@ -0,0 +1,212 @@
|
||||
use crate::config::BlueskyConfig;
|
||||
use atrium_api::{
|
||||
app::bsky::feed::post::RecordData, com::atproto::repo::upload_blob::Output,
|
||||
types::string::Datetime, types::string::Language,
|
||||
};
|
||||
use bsky_sdk::{rich_text::RichText, BskyAgent};
|
||||
use log::error;
|
||||
use megalodon::entities::attachment::{Attachment, AttachmentType};
|
||||
use regex::Regex;
|
||||
use std::error::Error;
|
||||
|
||||
/// Intermediary struct to deal with replies more easily
|
||||
#[derive(Debug)]
|
||||
pub struct BskyReply {
|
||||
pub record_uri: String,
|
||||
pub root_record_uri: String,
|
||||
}
|
||||
|
||||
pub async fn get_session(user: &str, pass: &str) -> Result<BskyAgent, Box<dyn Error>> {
|
||||
let agent = BskyAgent::builder().build().await?;
|
||||
agent.login(user, pass).await?;
|
||||
|
||||
Ok(agent)
|
||||
}
|
||||
|
||||
pub async fn build_post_record(
|
||||
config: &BlueskyConfig,
|
||||
text: &str,
|
||||
language: &Option<String>,
|
||||
embed: Option<atrium_api::types::Union<atrium_api::app::bsky::feed::post::RecordEmbedRefs>>,
|
||||
reply_to: &Option<BskyReply>,
|
||||
) -> Result<RecordData, Box<dyn Error>> {
|
||||
let mut rt = RichText::new_with_detect_facets(text).await?;
|
||||
|
||||
let insert_chars = "…";
|
||||
|
||||
let re = Regex::new(r#"(https?://)(\S{1,26})(\S*)"#).unwrap();
|
||||
|
||||
while let Some(found) = re.captures(&rt.text.clone()) {
|
||||
if let Some(group) = found.get(3) {
|
||||
rt.insert(group.start(), insert_chars);
|
||||
rt.delete(
|
||||
group.start() + insert_chars.len(),
|
||||
group.start() + insert_chars.len() + group.len(),
|
||||
);
|
||||
}
|
||||
if let Some(group) = found.get(1) {
|
||||
rt.delete(group.start(), group.start() + group.len());
|
||||
}
|
||||
}
|
||||
|
||||
let langs = language.clone().map(|s| vec![Language::new(s).unwrap()]);
|
||||
|
||||
let reply = if let Some(x) = reply_to {
|
||||
let root_record = get_record(&config.handle, &rkey(&x.root_record_uri)).await?;
|
||||
let parent_record = get_record(&config.handle, &rkey(&x.record_uri)).await?;
|
||||
|
||||
Some(
|
||||
atrium_api::app::bsky::feed::post::ReplyRefData {
|
||||
parent: atrium_api::com::atproto::repo::strong_ref::MainData {
|
||||
cid: parent_record.data.cid.unwrap(),
|
||||
uri: parent_record.data.uri.to_owned(),
|
||||
}
|
||||
.into(),
|
||||
root: atrium_api::com::atproto::repo::strong_ref::MainData {
|
||||
cid: root_record.data.cid.unwrap(),
|
||||
uri: root_record.data.uri.to_owned(),
|
||||
}
|
||||
.into(),
|
||||
}
|
||||
.into(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(RecordData {
|
||||
created_at: Datetime::now(),
|
||||
embed,
|
||||
entities: None,
|
||||
facets: rt.facets,
|
||||
labels: None,
|
||||
langs,
|
||||
reply,
|
||||
tags: None,
|
||||
text: rt.text,
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_record(
|
||||
config: &str,
|
||||
rkey: &str,
|
||||
) -> Result<
|
||||
atrium_api::types::Object<atrium_api::com::atproto::repo::get_record::OutputData>,
|
||||
Box<dyn Error>,
|
||||
> {
|
||||
let bsky = BskyAgent::builder().build().await?;
|
||||
let record = bsky
|
||||
.api
|
||||
.com
|
||||
.atproto
|
||||
.repo
|
||||
.get_record(
|
||||
atrium_api::com::atproto::repo::get_record::ParametersData {
|
||||
cid: None,
|
||||
collection: atrium_api::types::string::Nsid::new("app.bsky.feed.post".to_string())?,
|
||||
repo: atrium_api::types::string::Handle::new(config.to_string())?.into(),
|
||||
rkey: rkey.to_string(),
|
||||
}
|
||||
.into(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
// it’s ugly af but it gets the job done for now
|
||||
pub async fn generate_media_records(
|
||||
bsky: &BskyAgent,
|
||||
media_attach: &[Attachment],
|
||||
) -> Option<atrium_api::types::Union<atrium_api::app::bsky::feed::post::RecordEmbedRefs>> {
|
||||
let mut embed: Option<
|
||||
atrium_api::types::Union<atrium_api::app::bsky::feed::post::RecordEmbedRefs>,
|
||||
> = None;
|
||||
let mut images = Vec::new();
|
||||
let mut videos: Vec<atrium_api::app::bsky::embed::video::MainData> = Vec::new();
|
||||
|
||||
for media in media_attach.iter() {
|
||||
let blob = upload_media(bsky, &media.url).await.unwrap();
|
||||
|
||||
match media.r#type {
|
||||
AttachmentType::Image => {
|
||||
images.push(
|
||||
atrium_api::app::bsky::embed::images::ImageData {
|
||||
alt: media
|
||||
.description
|
||||
.clone()
|
||||
.map_or("".to_string(), |v| v.to_owned()),
|
||||
aspect_ratio: None,
|
||||
image: blob.data.blob,
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
AttachmentType::Gifv | AttachmentType::Video => {
|
||||
videos.push(atrium_api::app::bsky::embed::video::MainData {
|
||||
alt: media.description.clone(),
|
||||
aspect_ratio: None,
|
||||
captions: None,
|
||||
video: blob.data.blob,
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
error!("Not an image, not a video, what happened here?");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !images.is_empty() {
|
||||
embed = Some(atrium_api::types::Union::Refs(
|
||||
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedImagesMain(Box::new(
|
||||
atrium_api::app::bsky::embed::images::MainData { images }.into(),
|
||||
)),
|
||||
));
|
||||
}
|
||||
|
||||
// if a video has been uploaded, it takes priority as you can only have 1 video per post
|
||||
if !videos.is_empty() {
|
||||
embed = Some(atrium_api::types::Union::Refs(
|
||||
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedVideoMain(Box::new(
|
||||
videos[0].clone().into(),
|
||||
)),
|
||||
))
|
||||
}
|
||||
|
||||
embed
|
||||
}
|
||||
|
||||
async fn upload_media(bsky: &BskyAgent, u: &str) -> Result<Output, Box<dyn Error>> {
|
||||
let dl = reqwest::get(u).await?;
|
||||
let bytes = dl.bytes().await?;
|
||||
|
||||
let record = bsky.api.com.atproto.repo.upload_blob(bytes.into()).await?;
|
||||
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
fn rkey(record_id: &str) -> String {
|
||||
record_id.split('/').nth(4).unwrap().to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_build_post_record() {
|
||||
let text = "@factornews@piaille.fr Retrouvez-nous ici https://www.nintendojo.fr/articles/editos/le-mod-renovation-de-8bitdo-pour-manette-n64 et là https://www.nintendojo.fr/articles/analyses/vite-vu/vite-vu-morbid-the-lords-of-ire";
|
||||
let expected_text = "@factornews@piaille.fr Retrouvez-nous ici www.nintendojo.fr/articles… et là www.nintendojo.fr/articles…";
|
||||
|
||||
let bsky_conf = BlueskyConfig {
|
||||
handle: "tamerelol.bsky.social".to_string(),
|
||||
password: "dtc".to_string(),
|
||||
};
|
||||
|
||||
let created_record_data = build_post_record(&bsky_conf, text, &None, None, &None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(expected_text, &created_record_data.text);
|
||||
}
|
||||
}
|
@@ -6,9 +6,10 @@ pub struct Config {
|
||||
pub oolatoocs: OolatoocsConfig,
|
||||
pub mastodon: MastodonConfig,
|
||||
pub twitter: TwitterConfig,
|
||||
pub bluesky: BlueskyConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct TwitterConfig {
|
||||
pub consumer_key: String,
|
||||
pub consumer_secret: String,
|
||||
@@ -30,6 +31,12 @@ pub struct MastodonConfig {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct BlueskyConfig {
|
||||
pub handle: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// parses TOML file into Config struct
|
||||
pub fn parse_toml(toml_file: &str) -> Config {
|
||||
let toml_config =
|
||||
|
252
src/lib.rs
252
src/lib.rs
@@ -1,3 +1,5 @@
|
||||
use log::debug;
|
||||
|
||||
mod error;
|
||||
pub use error::OolatoocsError;
|
||||
|
||||
@@ -5,22 +7,22 @@ mod config;
|
||||
pub use config::{parse_toml, Config};
|
||||
|
||||
mod state;
|
||||
pub use state::init_db;
|
||||
#[allow(unused_imports)]
|
||||
use state::{read_state, write_state, TweetToToot};
|
||||
use state::{delete_state, read_all_state, read_state, write_state, TootTweetRecord};
|
||||
pub use state::{init_db, migrate_db};
|
||||
|
||||
mod mastodon;
|
||||
use mastodon::get_mastodon_timeline_since;
|
||||
pub use mastodon::register;
|
||||
use mastodon::{get_mastodon_instance, get_mastodon_timeline_since, get_status_edited_at};
|
||||
|
||||
mod utils;
|
||||
use utils::strip_everything;
|
||||
use utils::{generate_multi_tweets, strip_everything};
|
||||
|
||||
mod twitter;
|
||||
#[allow(unused_imports)]
|
||||
use twitter::{post_tweet, upload_chunk_media, upload_simple_media};
|
||||
use twitter::{delete_tweet, generate_media_ids, post_tweet, transform_poll};
|
||||
|
||||
mod bsky;
|
||||
use bsky::{build_post_record, generate_media_records, get_session, BskyReply};
|
||||
|
||||
use megalodon::entities::attachment::AttachmentType;
|
||||
use rusqlite::Connection;
|
||||
|
||||
#[tokio::main]
|
||||
@@ -28,75 +30,203 @@ pub async fn run(config: &Config) {
|
||||
let conn = Connection::open(&config.oolatoocs.db_path)
|
||||
.unwrap_or_else(|e| panic!("Cannot open DB: {}", e));
|
||||
|
||||
let last_toot_id = read_state(&conn, None)
|
||||
.unwrap_or_else(|e| panic!("Cannot get last toot id: {}", e))
|
||||
.map(|r| r.toot_id);
|
||||
let mastodon = get_mastodon_instance(&config.mastodon);
|
||||
|
||||
let timeline = get_mastodon_timeline_since(&config.mastodon, last_toot_id)
|
||||
let bluesky = get_session(&config.bluesky.handle, &config.bluesky.password)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("Cannot connect to Bsky: {}", e));
|
||||
|
||||
let last_entry =
|
||||
read_state(&conn, None).unwrap_or_else(|e| panic!("Cannot get last toot id: {}", e));
|
||||
|
||||
let last_toot_id: Option<u64> = match last_entry {
|
||||
None => None, // Does not exist, this is the same as previously
|
||||
Some(t) => {
|
||||
match get_status_edited_at(&mastodon, t.toot_id).await {
|
||||
None => Some(t.toot_id),
|
||||
Some(d) => {
|
||||
// a date has been found
|
||||
if d > t.datetime.unwrap() {
|
||||
debug!("Last toot date is posterior to the previously written tweet, deleting…");
|
||||
let (local_tweet_ids, local_record_uris) = read_all_state(&conn, t.toot_id)
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"Cannot fetch all tweets associated with Toot ID {}: {}",
|
||||
t.toot_id, e
|
||||
)
|
||||
});
|
||||
for local_tweet_id in local_tweet_ids.into_iter() {
|
||||
delete_tweet(&config.twitter, local_tweet_id)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
panic!("Cannot delete Tweet ID ({}): {}", t.tweet_id, e)
|
||||
});
|
||||
}
|
||||
for local_record_uri in local_record_uris.into_iter() {
|
||||
bluesky
|
||||
.delete_record(&local_record_uri)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
panic!("Cannot delete record ID ({}): {}", &t.record_uri, e)
|
||||
});
|
||||
}
|
||||
delete_state(&conn, t.toot_id).unwrap_or_else(|e| {
|
||||
panic!("Cannot delete Toot ID ({}): {}", t.toot_id, e)
|
||||
});
|
||||
read_state(&conn, None)
|
||||
.unwrap_or_else(|e| panic!("Cannot get last toot id: {}", e))
|
||||
.map(|a| a.toot_id)
|
||||
} else {
|
||||
Some(t.toot_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let timeline = get_mastodon_timeline_since(&mastodon, last_toot_id)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("Cannot get instance: {}", e));
|
||||
|
||||
for toot in timeline {
|
||||
let Ok(tweet_content) = strip_everything(&toot.content, &toot.tags) else {
|
||||
continue; // skip in case we can’t strip something
|
||||
};
|
||||
let mut medias: Vec<u64> = vec![];
|
||||
|
||||
// if we wanted to cut toot in half, now would be the right time to do so
|
||||
|
||||
// treats media
|
||||
for media in toot.media_attachments {
|
||||
let id = match media.r#type {
|
||||
AttachmentType::Image => {
|
||||
let Ok(id) =
|
||||
upload_simple_media(&config.twitter, &media.url, &media.description).await
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
id
|
||||
}
|
||||
AttachmentType::Gifv => {
|
||||
let Ok(id) = upload_chunk_media(&config.twitter, &media.url, "tweet_gif").await
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
id
|
||||
}
|
||||
AttachmentType::Video => {
|
||||
let Ok(id) =
|
||||
upload_chunk_media(&config.twitter, &media.url, "tweet_video").await
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
id
|
||||
}
|
||||
_ => {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
medias.push(id);
|
||||
// detecting tag #NoTweet and skipping the toot
|
||||
if toot.tags.iter().any(|f| &f.name == "notweet") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// form tweet_content and strip everything useless in it
|
||||
let Ok(mut tweet_content) = strip_everything(&toot.content, &toot.tags) else {
|
||||
continue; // skip in case we can’t strip something
|
||||
};
|
||||
|
||||
// threads if necessary
|
||||
let reply_to = toot.in_reply_to_id.and_then(|t| {
|
||||
read_state(&conn, Some(t.parse::<u64>().unwrap()))
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|s| s.tweet_id)
|
||||
});
|
||||
let (mut tweet_reply_to, mut record_reply_to) = toot
|
||||
.in_reply_to_id
|
||||
.and_then(|t| {
|
||||
read_state(&conn, Some(t.parse::<u64>().unwrap()))
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|s| {
|
||||
(
|
||||
s.tweet_id,
|
||||
BskyReply {
|
||||
record_uri: s.record_uri.to_owned(),
|
||||
root_record_uri: s.root_record_uri.to_owned(),
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
.unzip();
|
||||
|
||||
// if the toot is too long, we cut it in half here
|
||||
if let Some((first_half, second_half)) = generate_multi_tweets(&tweet_content) {
|
||||
tweet_content = second_half;
|
||||
// post the first half
|
||||
let tweet_reply_id =
|
||||
post_tweet(&config.twitter, &first_half, vec![], tweet_reply_to, None)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"Cannot post the first half of {} for Twitter: {}",
|
||||
&toot.id, e
|
||||
)
|
||||
});
|
||||
|
||||
let record = build_post_record(
|
||||
&config.bluesky,
|
||||
&first_half,
|
||||
&toot.language,
|
||||
None,
|
||||
&record_reply_to,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("Cannot create valid record for {}: {}", &toot.id, e));
|
||||
let record_reply_id = bluesky.create_record(record).await.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"Cannot post the first half of {} for Bluesky: {}",
|
||||
&toot.id, e
|
||||
)
|
||||
});
|
||||
// write it to db
|
||||
write_state(
|
||||
&conn,
|
||||
TootTweetRecord {
|
||||
toot_id: toot.id.parse::<u64>().unwrap(),
|
||||
tweet_id: tweet_reply_id,
|
||||
record_uri: record_reply_id.data.uri.to_owned(),
|
||||
root_record_uri: record_reply_to
|
||||
.as_ref()
|
||||
.map_or(record_reply_id.data.uri.to_owned(), |v| {
|
||||
v.root_record_uri.to_owned()
|
||||
}),
|
||||
datetime: None,
|
||||
},
|
||||
)
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"Cannot store Toot/Tweet/Record ({}/{}/{}): {}",
|
||||
&toot.id, tweet_reply_id, &record_reply_id.data.uri, e
|
||||
)
|
||||
});
|
||||
|
||||
record_reply_to = Some(BskyReply {
|
||||
record_uri: record_reply_id.data.uri.to_owned(),
|
||||
root_record_uri: record_reply_to
|
||||
.as_ref()
|
||||
.map_or(record_reply_id.data.uri.clone(), |v| {
|
||||
v.root_record_uri.clone()
|
||||
}),
|
||||
});
|
||||
|
||||
tweet_reply_to = Some(tweet_reply_id);
|
||||
};
|
||||
|
||||
// treats poll if any
|
||||
let in_poll = toot.poll.map(|p| transform_poll(&p));
|
||||
|
||||
// treats medias
|
||||
let record_medias = generate_media_records(&bluesky, &toot.media_attachments).await;
|
||||
let tweet_medias = generate_media_ids(&config.twitter, &toot.media_attachments).await;
|
||||
|
||||
// posts corresponding tweet
|
||||
let tweet_id = post_tweet(&config.twitter, &tweet_content, &medias, &reply_to)
|
||||
let tweet_id = post_tweet(
|
||||
&config.twitter,
|
||||
&tweet_content,
|
||||
tweet_medias,
|
||||
tweet_reply_to,
|
||||
in_poll,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("Cannot Tweet {}: {}", toot.id, e));
|
||||
|
||||
let record = build_post_record(
|
||||
&config.bluesky,
|
||||
&tweet_content,
|
||||
&toot.language,
|
||||
record_medias,
|
||||
&record_reply_to,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("Cannot build record for {}: {}", &toot.id, e));
|
||||
|
||||
let created_record = bluesky
|
||||
.create_record(record)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("Cannot Tweet {}: {}", toot.id, e));
|
||||
.unwrap_or_else(|e| panic!("Cannot put record {}: {}", &toot.id, e));
|
||||
|
||||
// writes the current state of the tweet
|
||||
write_state(
|
||||
&conn,
|
||||
TweetToToot {
|
||||
tweet_id,
|
||||
TootTweetRecord {
|
||||
toot_id: toot.id.parse::<u64>().unwrap(),
|
||||
tweet_id,
|
||||
record_uri: created_record.data.uri.clone(),
|
||||
root_record_uri: record_reply_to
|
||||
.as_ref()
|
||||
.map_or(created_record.data.uri.clone(), |v| {
|
||||
v.root_record_uri.clone()
|
||||
}),
|
||||
datetime: None,
|
||||
},
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("Cannot store Toot/Tweet ({}/{}): {}", &toot.id, tweet_id, e));
|
||||
|
20
src/main.rs
20
src/main.rs
@@ -49,6 +49,21 @@ fn main() {
|
||||
.display_order(1),
|
||||
),
|
||||
)
|
||||
.subcommand(
|
||||
Command::new("migrate")
|
||||
.version(env!("CARGO_PKG_VERSION"))
|
||||
.about("Command to register to Mastodon Instance")
|
||||
.arg(
|
||||
Arg::new("config")
|
||||
.short('c')
|
||||
.long("config")
|
||||
.value_name("CONFIG_FILE")
|
||||
.help(format!("TOML config file for {}", env!("CARGO_PKG_NAME")))
|
||||
.num_args(1)
|
||||
.default_value(DEFAULT_CONFIG_PATH)
|
||||
.display_order(1),
|
||||
),
|
||||
)
|
||||
.get_matches();
|
||||
|
||||
env_logger::init();
|
||||
@@ -63,6 +78,11 @@ fn main() {
|
||||
register(sub_m.get_one::<String>("host").unwrap());
|
||||
return;
|
||||
}
|
||||
Some(("migrate", sub_m)) => {
|
||||
let config = parse_toml(sub_m.get_one::<String>("config").unwrap());
|
||||
migrate_db(&config.oolatoocs.db_path).unwrap();
|
||||
return;
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
|
||||
|
@@ -1,4 +1,5 @@
|
||||
use crate::config::MastodonConfig;
|
||||
use chrono::{DateTime, Utc};
|
||||
use megalodon::{
|
||||
entities::{Status, StatusVisibility},
|
||||
generator,
|
||||
@@ -10,16 +11,29 @@ use megalodon::{
|
||||
use std::error::Error;
|
||||
use std::io::stdin;
|
||||
|
||||
pub async fn get_mastodon_timeline_since(
|
||||
config: &MastodonConfig,
|
||||
id: Option<u64>,
|
||||
) -> Result<Vec<Status>, Box<dyn Error>> {
|
||||
let mastodon = Mastodon::new(
|
||||
/// Get Mastodon Object instance
|
||||
pub fn get_mastodon_instance(config: &MastodonConfig) -> Mastodon {
|
||||
Mastodon::new(
|
||||
config.base.to_string(),
|
||||
Some(config.token.to_string()),
|
||||
None,
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
/// Get the edited_at field from the specified toot
|
||||
pub async fn get_status_edited_at(mastodon: &Mastodon, t: u64) -> Option<DateTime<Utc>> {
|
||||
mastodon
|
||||
.get_status(t.to_string())
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|t| t.json.edited_at)
|
||||
}
|
||||
|
||||
/// Get the home timeline since the last toot
|
||||
pub async fn get_mastodon_timeline_since(
|
||||
mastodon: &Mastodon,
|
||||
id: Option<u64>,
|
||||
) -> Result<Vec<Status>, Box<dyn Error>> {
|
||||
let input_options = GetHomeTimelineInputOptions {
|
||||
only_media: Some(false),
|
||||
limit: None,
|
||||
@@ -34,7 +48,6 @@ pub async fn get_mastodon_timeline_since(
|
||||
.await?
|
||||
.json()
|
||||
.iter()
|
||||
.cloned()
|
||||
.filter(|t| {
|
||||
// this excludes the reply to other users
|
||||
t.in_reply_to_account_id.is_none()
|
||||
@@ -45,6 +58,7 @@ pub async fn get_mastodon_timeline_since(
|
||||
.filter(|t| t.visibility == StatusVisibility::Public) // excludes everything that isn’t
|
||||
// public
|
||||
.filter(|t| t.reblog.is_none()) // excludes reblogs
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
timeline.reverse();
|
||||
|
323
src/state.rs
323
src/state.rs
@@ -1,12 +1,51 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use log::debug;
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use std::error::Error;
|
||||
|
||||
/// Struct for each query line
|
||||
#[derive(Debug)]
|
||||
pub struct TweetToToot {
|
||||
pub tweet_id: u64,
|
||||
pub struct TootTweetRecord {
|
||||
// Mastodon part
|
||||
pub toot_id: u64,
|
||||
// Twitter part
|
||||
pub tweet_id: u64,
|
||||
// Bluesky part
|
||||
pub record_uri: String,
|
||||
pub root_record_uri: String,
|
||||
pub datetime: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Deletes a given state
|
||||
pub fn delete_state(conn: &Connection, toot_id: u64) -> Result<(), Box<dyn Error>> {
|
||||
debug!("Deleting Toot ID {}", toot_id);
|
||||
conn.execute(
|
||||
&format!("DELETE FROM toot_tweet_record WHERE toot_id = {}", toot_id),
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retrieves all tweets associated to a toot in the form of a vector
|
||||
pub fn read_all_state(
|
||||
conn: &Connection,
|
||||
toot_id: u64,
|
||||
) -> Result<(Vec<u64>, Vec<String>), Box<dyn Error>> {
|
||||
let query = format!(
|
||||
"SELECT tweet_id, record_uri FROM toot_tweet_record WHERE toot_id = {};",
|
||||
toot_id
|
||||
);
|
||||
let mut stmt = conn.prepare(&query)?;
|
||||
let mut rows = stmt.query([])?;
|
||||
|
||||
let mut tweet_v: Vec<u64> = Vec::new();
|
||||
let mut record_v: Vec<String> = Vec::new();
|
||||
while let Some(row) = rows.next()? {
|
||||
tweet_v.push(row.get(0)?);
|
||||
record_v.push(row.get(1)?);
|
||||
}
|
||||
|
||||
Ok((tweet_v, record_v))
|
||||
}
|
||||
|
||||
/// if None is passed, read the last tweet from DB
|
||||
@@ -14,20 +53,26 @@ pub struct TweetToToot {
|
||||
pub fn read_state(
|
||||
conn: &Connection,
|
||||
s: Option<u64>,
|
||||
) -> Result<Option<TweetToToot>, Box<dyn Error>> {
|
||||
) -> Result<Option<TootTweetRecord>, Box<dyn Error>> {
|
||||
debug!("Reading toot_id {:?}", s);
|
||||
let begin_query = "SELECT *, UNIXEPOCH(datetime) AS unix_datetime FROM toot_tweet_record";
|
||||
let query: String = match s {
|
||||
Some(i) => format!("SELECT * FROM tweet_to_toot WHERE toot_id = {i}"),
|
||||
None => "SELECT * FROM tweet_to_toot ORDER BY toot_id DESC LIMIT 1".to_string(),
|
||||
Some(i) => format!("{begin_query} WHERE toot_id = {i} ORDER BY tweet_id DESC LIMIT 1"),
|
||||
None => format!("{begin_query} ORDER BY toot_id DESC LIMIT 1"),
|
||||
};
|
||||
|
||||
let mut stmt = conn.prepare(&query)?;
|
||||
|
||||
let t = stmt
|
||||
.query_row([], |row| {
|
||||
Ok(TweetToToot {
|
||||
tweet_id: row.get("tweet_id")?,
|
||||
Ok(TootTweetRecord {
|
||||
toot_id: row.get("toot_id")?,
|
||||
tweet_id: row.get("tweet_id")?,
|
||||
record_uri: row.get("record_uri")?,
|
||||
root_record_uri: row.get("root_record_uri")?,
|
||||
datetime: Some(
|
||||
DateTime::from_timestamp(row.get("unix_datetime").unwrap(), 0).unwrap(),
|
||||
),
|
||||
})
|
||||
})
|
||||
.optional()?;
|
||||
@@ -36,11 +81,11 @@ pub fn read_state(
|
||||
}
|
||||
|
||||
/// Writes last treated tweet id and toot id to the db
|
||||
pub fn write_state(conn: &Connection, t: TweetToToot) -> Result<(), Box<dyn Error>> {
|
||||
pub fn write_state(conn: &Connection, t: TootTweetRecord) -> Result<(), Box<dyn Error>> {
|
||||
debug!("Write struct {:?}", t);
|
||||
conn.execute(
|
||||
"INSERT INTO tweet_to_toot (tweet_id, toot_id) VALUES (?1, ?2)",
|
||||
params![t.tweet_id, t.toot_id],
|
||||
"INSERT INTO toot_tweet_record (toot_id, tweet_id, record_uri, root_record_uri) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![t.toot_id, t.tweet_id, t.record_uri, t.root_record_uri],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
@@ -55,9 +100,12 @@ pub fn init_db(d: &str) -> Result<(), Box<dyn Error>> {
|
||||
let conn = Connection::open(d)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS tweet_to_toot (
|
||||
tweet_id INTEGER,
|
||||
toot_id INTEGER PRIMARY KEY
|
||||
"CREATE TABLE IF NOT EXISTS toot_tweet_record (
|
||||
toot_id INTEGER,
|
||||
tweet_id INTEGER PRIMARY KEY,
|
||||
record_uri VARCHAR(128) DEFAULT '',
|
||||
root_record_uri VARCHAR(128) DEFAULT '',
|
||||
datetime INTEGER DEFAULT CURRENT_TIMESTAMP
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
@@ -65,6 +113,55 @@ pub fn init_db(d: &str) -> Result<(), Box<dyn Error>> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Migrate DB from 1.6+ to 3+
|
||||
pub fn migrate_db(d: &str) -> Result<(), Box<dyn Error>> {
|
||||
debug!("Migration DB for Oolatoocs");
|
||||
|
||||
let conn = Connection::open(d)?;
|
||||
|
||||
let res = conn.execute("SELECT datetime FROM toot_tweet_record;", []);
|
||||
|
||||
// If the column can be selected then, it’s OK
|
||||
// if not, see if the error is a missing column and add it
|
||||
match res {
|
||||
Err(e) => match e.to_string().as_str() {
|
||||
"no such table: toot_tweet_record" => migrate_db_alter_table(&conn), // table does not exist
|
||||
"Execute returned results - did you mean to call query?" => Ok(()), // return results,
|
||||
// column does
|
||||
// exist
|
||||
_ => Err(e.into()),
|
||||
},
|
||||
Ok(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new table, copy the data from the old table and rename it
|
||||
fn migrate_db_alter_table(c: &Connection) -> Result<(), Box<dyn Error>> {
|
||||
// create the new table
|
||||
c.execute(
|
||||
"CREATE TABLE IF NOT EXISTS toot_tweet_record (
|
||||
toot_id INTEGER,
|
||||
tweet_id INTEGER PRIMARY KEY,
|
||||
record_uri VARCHAR(128) DEFAULT '',
|
||||
root_record_uri VARCHAR(128) DEFAULT '',
|
||||
datetime INTEGER DEFAULT CURRENT_TIMESTAMP
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// copy data from the old table
|
||||
c.execute(
|
||||
"INSERT INTO toot_tweet_record (toot_id, tweet_id, datetime)
|
||||
SELECT toot_id, tweet_id, datetime FROM tweet_to_toot;",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// drop the old table
|
||||
c.execute("DROP TABLE IF EXISTS tweet_to_toot;", [])?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -81,7 +178,8 @@ mod tests {
|
||||
|
||||
// open said file
|
||||
let conn = Connection::open(d).unwrap();
|
||||
conn.execute("SELECT * from tweet_to_toot;", []).unwrap();
|
||||
conn.execute("SELECT * from toot_tweet_record;", [])
|
||||
.unwrap();
|
||||
|
||||
remove_file(d).unwrap();
|
||||
}
|
||||
@@ -96,7 +194,7 @@ mod tests {
|
||||
let conn = Connection::open(d).unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO tweet_to_toot (tweet_id, toot_id)
|
||||
"INSERT INTO toot_tweet_record (tweet_id, toot_id)
|
||||
VALUES
|
||||
(100, 1001);",
|
||||
[],
|
||||
@@ -116,26 +214,38 @@ mod tests {
|
||||
|
||||
let conn = Connection::open(d).unwrap();
|
||||
|
||||
let t_in = TweetToToot {
|
||||
tweet_id: 123456789,
|
||||
let t_in = TootTweetRecord {
|
||||
toot_id: 987654321,
|
||||
tweet_id: 123456789,
|
||||
record_uri: "a".to_string(),
|
||||
root_record_uri: "c".to_string(),
|
||||
datetime: None,
|
||||
};
|
||||
|
||||
write_state(&conn, t_in).unwrap();
|
||||
|
||||
let mut stmt = conn.prepare("SELECT * FROM tweet_to_toot;").unwrap();
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT *, UNIXEPOCH(datetime) AS unix_datetime FROM toot_tweet_record;")
|
||||
.unwrap();
|
||||
|
||||
let t_out = stmt
|
||||
.query_row([], |row| {
|
||||
Ok(TweetToToot {
|
||||
tweet_id: row.get("tweet_id").unwrap(),
|
||||
Ok(TootTweetRecord {
|
||||
toot_id: row.get("toot_id").unwrap(),
|
||||
tweet_id: row.get("tweet_id").unwrap(),
|
||||
record_uri: row.get("record_uri").unwrap(),
|
||||
root_record_uri: row.get("root_record_uri").unwrap(),
|
||||
datetime: Some(
|
||||
DateTime::from_timestamp(row.get("unix_datetime").unwrap(), 0).unwrap(),
|
||||
),
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(t_out.tweet_id, 123456789);
|
||||
assert_eq!(t_out.toot_id, 987654321);
|
||||
assert_eq!(t_out.tweet_id, 123456789);
|
||||
assert_eq!(t_out.record_uri, "a".to_string());
|
||||
assert_eq!(t_out.root_record_uri, "c".to_string());
|
||||
|
||||
remove_file(d).unwrap();
|
||||
}
|
||||
@@ -149,10 +259,10 @@ mod tests {
|
||||
let conn = Connection::open(d).unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO tweet_to_toot (tweet_id, toot_id)
|
||||
"INSERT INTO toot_tweet_record (toot_id, tweet_id, record_uri)
|
||||
VALUES
|
||||
(101, 1001),
|
||||
(102, 1002);",
|
||||
(101, 1001, 'abc'),
|
||||
(102, 1002, 'def');",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
@@ -161,8 +271,9 @@ mod tests {
|
||||
|
||||
remove_file(d).unwrap();
|
||||
|
||||
assert_eq!(t_out.tweet_id, 102);
|
||||
assert_eq!(t_out.toot_id, 1002);
|
||||
assert_eq!(t_out.toot_id, 102);
|
||||
assert_eq!(t_out.tweet_id, 1002);
|
||||
assert_eq!(t_out.record_uri, "def".to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -189,9 +300,9 @@ mod tests {
|
||||
let conn = Connection::open(d).unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO tweet_to_toot (tweet_id, toot_id)
|
||||
"INSERT INTO toot_tweet_record (toot_id, tweet_id, record_uri)
|
||||
VALUES
|
||||
(100, 1000);",
|
||||
(100, 1000, 'abc');",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
@@ -212,9 +323,35 @@ mod tests {
|
||||
let conn = Connection::open(d).unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO tweet_to_toot (tweet_id, toot_id)
|
||||
"INSERT INTO toot_tweet_record (toot_id, tweet_id, record_uri)
|
||||
VALUES
|
||||
(100, 1000);",
|
||||
(100, 1000, 'abc');",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let t_out = read_state(&conn, Some(100)).unwrap().unwrap();
|
||||
|
||||
remove_file(d).unwrap();
|
||||
|
||||
assert_eq!(t_out.toot_id, 100);
|
||||
assert_eq!(t_out.tweet_id, 1000);
|
||||
assert_eq!(t_out.record_uri, "abc".to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_last_toot_id_read_state() {
|
||||
let d = "/tmp/test_last_toot_id_read_state.sqlite";
|
||||
|
||||
init_db(d).unwrap();
|
||||
|
||||
let conn = Connection::open(d).unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO toot_tweet_record (toot_id, tweet_id, record_uri)
|
||||
VALUES
|
||||
(1000, 100, 'abc'),
|
||||
(1000, 101, 'def');",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
@@ -223,7 +360,131 @@ mod tests {
|
||||
|
||||
remove_file(d).unwrap();
|
||||
|
||||
assert_eq!(t_out.tweet_id, 100);
|
||||
assert_eq!(t_out.toot_id, 1000);
|
||||
assert_eq!(t_out.tweet_id, 101);
|
||||
assert_eq!(t_out.record_uri, "def".to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_db() {
|
||||
// this should be idempotent
|
||||
let d = "/tmp/test_migrate_db.sqlite";
|
||||
|
||||
let conn = Connection::open(d).unwrap();
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS tweet_to_toot (
|
||||
tweet_id INTEGER,
|
||||
toot_id INTEGER PRIMARY KEY,
|
||||
datetime INTEGER DEFAULT CURRENT_TIMESTAMP
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO tweet_to_toot (tweet_id, toot_id) VALUES (0, 0), (1, 1);",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
migrate_db(d).unwrap();
|
||||
|
||||
let last_state = read_state(&conn, None).unwrap().unwrap();
|
||||
|
||||
assert_eq!(last_state.tweet_id, 1);
|
||||
assert_eq!(last_state.toot_id, 1);
|
||||
|
||||
migrate_db(d).unwrap(); // shouldn’t do anything
|
||||
|
||||
remove_file(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_state() {
|
||||
let d = "/tmp/test_delete_state.sqlite";
|
||||
|
||||
init_db(d).unwrap();
|
||||
|
||||
let conn = Connection::open(d).unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO toot_tweet_record(toot_id, tweet_id, record_uri) VALUES (0, 0, 'abc');",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
delete_state(&conn, 0).unwrap();
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT *, UNIXEPOCH(datetime) AS unix_datetime FROM toot_tweet_record;")
|
||||
.unwrap();
|
||||
|
||||
let t_out = stmt.query_row([], |row| {
|
||||
Ok(TootTweetRecord {
|
||||
toot_id: row.get("toot_id").unwrap(),
|
||||
tweet_id: row.get("tweet_id").unwrap(),
|
||||
record_uri: row.get("record_uri").unwrap(),
|
||||
root_record_uri: row.get("root_record_uri").unwrap(),
|
||||
datetime: Some(
|
||||
DateTime::from_timestamp(row.get("unix_datetime").unwrap(), 0).unwrap(),
|
||||
),
|
||||
})
|
||||
});
|
||||
|
||||
assert!(t_out.is_err_and(|x| x == rusqlite::Error::QueryReturnedNoRows));
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO toot_tweet_record(toot_id, tweet_id, record_uri) VALUES(42, 102, 'abc'), (42, 103, 'def');",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
delete_state(&conn, 42).unwrap();
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT *, UNIXEPOCH(datetime) AS unix_datetime FROM toot_tweet_record;")
|
||||
.unwrap();
|
||||
|
||||
let t_out = stmt.query_row([], |row| {
|
||||
Ok(TootTweetRecord {
|
||||
toot_id: row.get("toot_id").unwrap(),
|
||||
tweet_id: row.get("tweet_id").unwrap(),
|
||||
record_uri: row.get("record_uri").unwrap(),
|
||||
root_record_uri: row.get("root_record_uri").unwrap(),
|
||||
datetime: Some(
|
||||
DateTime::from_timestamp(row.get("unix_datetime").unwrap(), 0).unwrap(),
|
||||
),
|
||||
})
|
||||
});
|
||||
|
||||
assert!(t_out.is_err_and(|x| x == rusqlite::Error::QueryReturnedNoRows));
|
||||
|
||||
remove_file(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_all_state() {
|
||||
let d = "/tmp/read_all_state.sqlite";
|
||||
|
||||
init_db(d).unwrap();
|
||||
|
||||
let conn = Connection::open(d).unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO toot_tweet_record (toot_id, tweet_id, record_uri) VALUES (42, 102, 'abc'), (42, 103, 'def'), (43, 105, 'ghi');",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (tweet_v1, record_v1) = read_all_state(&conn, 43).unwrap();
|
||||
let (tweet_v2, record_v2) = read_all_state(&conn, 42).unwrap();
|
||||
|
||||
assert_eq!(tweet_v1, vec![105]);
|
||||
assert_eq!(tweet_v2, vec![102, 103]);
|
||||
|
||||
assert_eq!(record_v1, vec!["ghi".to_string()]);
|
||||
assert_eq!(record_v2, vec!["abc".to_string(), "def".to_string()]);
|
||||
|
||||
remove_file(d).unwrap();
|
||||
}
|
||||
}
|
||||
|
161
src/twitter.rs
161
src/twitter.rs
@@ -1,6 +1,12 @@
|
||||
use crate::config::TwitterConfig;
|
||||
use crate::error::OolatoocsError;
|
||||
use log::debug;
|
||||
use chrono::Utc;
|
||||
use futures::{stream, StreamExt};
|
||||
use log::{debug, error, warn};
|
||||
use megalodon::entities::{
|
||||
attachment::{Attachment, AttachmentType},
|
||||
Poll,
|
||||
};
|
||||
use oauth1_request::Token;
|
||||
use reqwest::{
|
||||
multipart::{Form, Part},
|
||||
@@ -26,6 +32,8 @@ struct Tweet {
|
||||
media: Option<TweetMediasIds>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
reply: Option<TweetReply>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
poll: Option<TweetPoll>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
@@ -38,6 +46,12 @@ struct TweetReply {
|
||||
in_reply_to_tweet_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct TweetPoll {
|
||||
pub options: Vec<String>,
|
||||
pub duration_minutes: u16,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct TweetResponse {
|
||||
data: TweetResponseData,
|
||||
@@ -99,12 +113,83 @@ fn get_token(config: &TwitterConfig) -> Token {
|
||||
)
|
||||
}
|
||||
|
||||
/// This functions deletes a tweet, given its id
|
||||
pub async fn delete_tweet(config: &TwitterConfig, id: u64) -> Result<(), Box<dyn Error>> {
|
||||
debug!("Deleting Tweet {}", id);
|
||||
let empty_request = EmptyRequest {}; // Why? Because fuck you, that’s why!
|
||||
let token = get_token(config);
|
||||
let delete_uri = format!("{}/{}", TWITTER_API_TWEET_URL, id);
|
||||
|
||||
let client = Client::new();
|
||||
let res = client
|
||||
.delete(&delete_uri)
|
||||
.header(
|
||||
"Authorization",
|
||||
oauth1_request::delete(
|
||||
&delete_uri,
|
||||
&empty_request,
|
||||
&token,
|
||||
oauth1_request::HMAC_SHA1,
|
||||
),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
return Err(OolatoocsError::new(&format!("Cannot delete Tweet {}", id)).into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// This function generates a media_ids vec to be used by Twitter
|
||||
pub async fn generate_media_ids(config: &TwitterConfig, media_attach: &[Attachment]) -> Vec<u64> {
|
||||
let mut medias: Vec<u64> = vec![];
|
||||
|
||||
let media_attachments = media_attach.to_owned();
|
||||
let mut stream = stream::iter(media_attachments)
|
||||
.map(|media| {
|
||||
let twitter_config = config.clone();
|
||||
tokio::task::spawn(async move {
|
||||
match media.r#type {
|
||||
AttachmentType::Image => {
|
||||
upload_simple_media(&twitter_config, &media.url, &media.description).await
|
||||
}
|
||||
AttachmentType::Gifv => {
|
||||
upload_chunk_media(&twitter_config, &media.url, "tweet_gif").await
|
||||
}
|
||||
AttachmentType::Video => {
|
||||
upload_chunk_media(&twitter_config, &media.url, "tweet_video").await
|
||||
}
|
||||
_ => Err::<u64, Box<dyn Error + Send + Sync>>(
|
||||
OolatoocsError::new(&format!(
|
||||
"Cannot treat this type of media: {}",
|
||||
&media.url
|
||||
))
|
||||
.into(),
|
||||
),
|
||||
}
|
||||
})
|
||||
})
|
||||
.buffered(4);
|
||||
|
||||
while let Some(result) = stream.next().await {
|
||||
match result {
|
||||
Ok(Ok(v)) => medias.push(v),
|
||||
Ok(Err(e)) => warn!("Cannot treat media: {}", e),
|
||||
Err(e) => error!("Something went wrong when joining the main thread: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
medias
|
||||
}
|
||||
|
||||
/// This function uploads simple images from Mastodon to Twitter and returns the media id from Twitter
|
||||
pub async fn upload_simple_media(
|
||||
async fn upload_simple_media(
|
||||
config: &TwitterConfig,
|
||||
u: &str,
|
||||
d: &Option<String>,
|
||||
) -> Result<u64, Box<dyn Error>> {
|
||||
) -> Result<u64, Box<dyn Error + Send + Sync>> {
|
||||
// initiate request parameters
|
||||
let empty_request = EmptyRequest {}; // Why? Because fuck you, that’s why!
|
||||
let token = get_token(config);
|
||||
@@ -152,7 +237,11 @@ pub async fn upload_simple_media(
|
||||
}
|
||||
|
||||
/// This function updates the metadata given the current media_id and token
|
||||
async fn metadata_create(config: &TwitterConfig, id: u64, m: &str) -> Result<(), Box<dyn Error>> {
|
||||
async fn metadata_create(
|
||||
config: &TwitterConfig,
|
||||
id: u64,
|
||||
m: &str,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let token = get_token(config);
|
||||
let empty_request = EmptyRequest {};
|
||||
|
||||
@@ -187,11 +276,11 @@ async fn metadata_create(config: &TwitterConfig, id: u64, m: &str) -> Result<(),
|
||||
}
|
||||
|
||||
/// This posts video/gif to Twitter and returns the media id from Twitter
|
||||
pub async fn upload_chunk_media(
|
||||
async fn upload_chunk_media(
|
||||
config: &TwitterConfig,
|
||||
u: &str,
|
||||
t: &str,
|
||||
) -> Result<u64, Box<dyn Error>> {
|
||||
) -> Result<u64, Box<dyn Error + Send + Sync>> {
|
||||
let empty_request = EmptyRequest {};
|
||||
let token = get_token(config);
|
||||
|
||||
@@ -369,12 +458,29 @@ pub async fn upload_chunk_media(
|
||||
Ok(orig_media_id.media_id)
|
||||
}
|
||||
|
||||
pub fn transform_poll(p: &Poll) -> TweetPoll {
|
||||
let poll_end_datetime = p.expires_at.unwrap(); // should be safe at this point
|
||||
let now = Utc::now();
|
||||
let diff = poll_end_datetime.signed_duration_since(now);
|
||||
|
||||
TweetPoll {
|
||||
options: p
|
||||
.options
|
||||
.iter()
|
||||
.map(|i| i.title.chars().take(25).collect::<String>())
|
||||
.collect(),
|
||||
duration_minutes: diff.num_minutes().try_into().unwrap(), // safe here, number is positive
|
||||
// and can’t be over 21600
|
||||
}
|
||||
}
|
||||
|
||||
/// This posts Tweets with all the associated medias
|
||||
pub async fn post_tweet(
|
||||
config: &TwitterConfig,
|
||||
content: &str,
|
||||
medias: &[u64],
|
||||
reply_to: &Option<u64>,
|
||||
medias: Vec<u64>,
|
||||
reply_to: Option<u64>,
|
||||
poll: Option<TweetPoll>,
|
||||
) -> Result<u64, Box<dyn Error>> {
|
||||
let empty_request = EmptyRequest {}; // Why? Because fuck you, that’s why!
|
||||
let token = get_token(config);
|
||||
@@ -387,6 +493,7 @@ pub async fn post_tweet(
|
||||
reply: reply_to.map(|s| TweetReply {
|
||||
in_reply_to_tweet_id: s.to_string(),
|
||||
}),
|
||||
poll,
|
||||
};
|
||||
|
||||
let client = Client::new();
|
||||
@@ -409,3 +516,41 @@ pub async fn post_tweet(
|
||||
|
||||
Ok(res.data.id.parse::<u64>().unwrap())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use megalodon::entities::PollOption;
|
||||
|
||||
#[test]
|
||||
fn test_transform_poll() {
|
||||
let poll = Poll {
|
||||
id: "youpi".to_string(),
|
||||
expires_at: Some(Utc::now()),
|
||||
expired: false,
|
||||
multiple: false,
|
||||
votes_count: 0,
|
||||
voters_count: None,
|
||||
options: vec![
|
||||
PollOption {
|
||||
title: "Je suis beaucoup trop long comme option, tronque-moi !".to_string(),
|
||||
votes_count: None,
|
||||
},
|
||||
PollOption {
|
||||
title: "nope".to_string(),
|
||||
votes_count: None,
|
||||
},
|
||||
],
|
||||
voted: None,
|
||||
emojis: vec![],
|
||||
};
|
||||
|
||||
let tweet_poll_res = transform_poll(&poll);
|
||||
let tweet_pool_expected = TweetPoll {
|
||||
duration_minutes: 0,
|
||||
options: vec!["Je suis beaucoup trop lon".to_string(), "nope".to_string()],
|
||||
};
|
||||
|
||||
assert_eq!(tweet_poll_res.options, tweet_pool_expected.options);
|
||||
}
|
||||
}
|
||||
|
164
src/utils.rs
164
src/utils.rs
@@ -1,15 +1,59 @@
|
||||
use dissolve::strip_html_tags;
|
||||
use html_escape::decode_html_entities;
|
||||
use megalodon::entities::status::Tag;
|
||||
use regex::Regex;
|
||||
use std::error::Error;
|
||||
|
||||
/// Generate 2 contents out of 1 if that content is > 280 chars, None else
|
||||
pub fn generate_multi_tweets(content: &str) -> Option<(String, String)> {
|
||||
// Twitter webforms are utf-8 encoded, so we cannot count on len(), we don’t need
|
||||
// encode_utf16().count()
|
||||
if twitter_count(content) <= 280 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let split_content = content.split(' ');
|
||||
|
||||
let split_count = split_content.clone().count();
|
||||
|
||||
let first_half: String = split_content
|
||||
.clone()
|
||||
.take(split_count / 2)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let second_half: String = split_content
|
||||
.clone()
|
||||
.skip(split_count / 2)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
Some((first_half, second_half))
|
||||
}
|
||||
|
||||
/// Twitter doesn’t count words the same we do, so you’ll have to improvise
|
||||
fn twitter_count(content: &str) -> usize {
|
||||
let mut count = 0;
|
||||
|
||||
let split_content = content.split(&[' ', '\n']);
|
||||
count += split_content.clone().count() - 1; // count the spaces
|
||||
|
||||
for word in split_content {
|
||||
if word.starts_with("http://") || word.starts_with("https://") {
|
||||
count += 23;
|
||||
} else {
|
||||
count += word.chars().count();
|
||||
}
|
||||
}
|
||||
|
||||
count
|
||||
}
|
||||
|
||||
pub fn strip_everything(content: &str, tags: &Vec<Tag>) -> Result<String, Box<dyn Error>> {
|
||||
let mut res =
|
||||
strip_html_tags(&content.replace("</p><p>", "\n\n").replace("<br />", "\n")).join("");
|
||||
let mut res = strip_html_tags(&content.replace("</p><p>", "\n\n").replace("<br />", "\n"));
|
||||
|
||||
strip_mastodon_tags(&mut res, tags).unwrap();
|
||||
|
||||
res = res.trim_end_matches('\n').trim_end_matches(' ').to_string();
|
||||
res = decode_html_entities(&res).to_string();
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
@@ -22,3 +66,117 @@ fn strip_mastodon_tags(content: &mut String, tags: &Vec<Tag>) -> Result<(), Box<
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn strip_html_tags(input: &str) -> String {
|
||||
let mut data = String::new();
|
||||
let mut inside = false;
|
||||
|
||||
for c in input.chars() {
|
||||
if c == '<' {
|
||||
inside = true;
|
||||
continue;
|
||||
}
|
||||
if c == '>' {
|
||||
inside = false;
|
||||
continue;
|
||||
}
|
||||
if !inside {
|
||||
data.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
data
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_twitter_count() {
|
||||
let content = "tamerelol?! 🐵";
|
||||
|
||||
assert_eq!(twitter_count(content), content.chars().count());
|
||||
|
||||
let content = "Shoot out to https://y.ml/ !";
|
||||
|
||||
assert_eq!(twitter_count(content), 38);
|
||||
|
||||
let content = "this is the link https://www.google.com/tamerelol/youpi/tonperemdr/tarace.html if you like! What if I shit a final";
|
||||
|
||||
assert_eq!(twitter_count(content), 76);
|
||||
|
||||
let content = "multi ple space";
|
||||
|
||||
assert_eq!(twitter_count(content), content.chars().count());
|
||||
|
||||
let content = "This link is LEEEEET\n\nhttps://www.factornews.com/actualites/ca-sent-le-sapin-pour-free-radical-design-49985.html";
|
||||
|
||||
assert_eq!(twitter_count(content), 45);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_multi_tweets_to_none() {
|
||||
// test « standard » text
|
||||
let tweet_content =
|
||||
"LOLOLOL, je suis bien trop petit pour être coupé en deux voyons :troll:".to_string();
|
||||
|
||||
let youpi = generate_multi_tweets(&tweet_content);
|
||||
|
||||
assert_eq!(None, youpi);
|
||||
|
||||
// test with « complex » emoji (2 utf-8 chars)
|
||||
let tweet_content = "🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷".to_string();
|
||||
|
||||
let youpi = generate_multi_tweets(&tweet_content);
|
||||
|
||||
assert_eq!(None, youpi);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_multi_tweets_to_some() {
|
||||
let tweet_content = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ipsum dolor sit amet consectetur adipiscing elit pellentesque. Pharetra pharetra massa massa ultricies mi quis hendrerit dolor. Mauris nunc congue nisi vitae. Scelerisque varius morbi enim nunc faucibus a pellentesque sit amet. Morbi leo urna molestie at elementum. Tristique et egestas quis ipsum suspendisse ultrices gravida dictum fusce. Amet porttitor eget dolor morbi.".to_string();
|
||||
|
||||
let youpi = generate_multi_tweets(&tweet_content);
|
||||
|
||||
let first_half = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ipsum dolor sit amet consectetur adipiscing elit pellentesque. Pharetra pharetra massa massa ultricies mi quis hendrerit dolor.".to_string();
|
||||
let second_half = "Mauris nunc congue nisi vitae. Scelerisque varius morbi enim nunc faucibus a pellentesque sit amet. Morbi leo urna molestie at elementum. Tristique et egestas quis ipsum suspendisse ultrices gravida dictum fusce. Amet porttitor eget dolor morbi.".to_string();
|
||||
|
||||
assert_eq!(youpi, Some((first_half, second_half)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_mastodon_tags() {
|
||||
let tags = vec![
|
||||
Tag {
|
||||
name: "putaclic".to_string(),
|
||||
url: "https://m.nintendojo.fr/tags/putaclic".to_string(),
|
||||
},
|
||||
Tag {
|
||||
name: "tamerelol".to_string(),
|
||||
url: "https://m.nintendojo.fr/tags/tamerelol".to_string(),
|
||||
},
|
||||
Tag {
|
||||
name: "JeFaisNawakEnCamelCase".to_string(),
|
||||
url: "https://m.nintendojo.fr/tags/jefaisnawakencamelcase".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
let mut content =
|
||||
"C’est super ça ! #putaclic #TAMERELOL #JeFaisNawakEnCamelCase".to_string();
|
||||
let sample = "C’est super ça ! ".to_string();
|
||||
|
||||
strip_mastodon_tags(&mut content, &tags).unwrap();
|
||||
|
||||
assert_eq!(content, sample);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_everything() {
|
||||
let content = "<p>Ce soir à 21h, c'est le Dojobar ! Au programme ce soir, une rétrospective sur la série Mario & Luigi.<br />Comme d'hab, le Twitch sera ici : <a href=\"https://twitch.tv/nintendojofr\" target=\"_blank\" rel=\"nofollow noopener noreferrer\" translate=\"no\"><span class=\"invisible\">https://</span><span class=\"\">twitch.tv/nintendojofr</span><span class=\"invisible\"></span></a><br />Ou juste l'audio là : <a href=\"https://nintendojo.fr/dojobar\" target=\"_blank\" rel=\"nofollow noopener noreferrer\" translate=\"no\"><span class=\"invisible\">https://</span><span class=\"\">nintendojo.fr/dojobar</span><span class=\"invisible\"></span></a><br />A toute !</p>";
|
||||
let expected_result = "Ce soir à 21h, c'est le Dojobar ! Au programme ce soir, une rétrospective sur la série Mario & Luigi.\nComme d'hab, le Twitch sera ici : https://twitch.tv/nintendojofr\nOu juste l'audio là : https://nintendojo.fr/dojobar\nA toute !".to_string();
|
||||
let result = strip_everything(content, &vec![]).unwrap();
|
||||
|
||||
assert_eq!(result, expected_result);
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user