19 Commits

Author SHA1 Message Date
VC
f10baa3eb2 Merge branch '1-find-a-way-to-not-carry-a-toot' into 'main'
Find a way to not carry a toot

Closes #1

See merge request veretcle/oolatoocs!6
2023-11-21 13:03:01 +00:00
VC
c113c1472a doc: add README 2023-11-21 13:59:14 +01:00
VC
cdf7dc70c1 feat: add #NoTweet to skip toot from being tweeted 2023-11-21 13:27:37 +01:00
VC
b1aed34f3c Merge branch '3-cut-toot-in-half-when-they-re-too-big' into 'main'
Cut toot in half

Closes #3

See merge request veretcle/oolatoocs!5
2023-11-20 14:53:19 +00:00
VC
e8bde4c779 feat: move media generation list to twitter.rs to avoid clutter 2023-11-20 15:32:02 +01:00
VC
80946ac131 chore: cargo update 2023-11-20 15:32:02 +01:00
VC
87b0567b59 feat: split toot into 2 tweets when necessary 2023-11-20 15:32:02 +01:00
VC
b6f87e829f Merge branch '2-find-a-way-to-remove-dissolve' into 'main'
feat: remove dissolve + add simpler html tag stripper + html entities

Closes #2

See merge request veretcle/oolatoocs!4
2023-11-17 19:30:36 +00:00
VC
6fccbf8d16 feat: remove dissolve + add simpler html tag stripper + html entities 2023-11-17 20:08:07 +01:00
VC
1fdea7f69d Merge branch 'parallel_medias' into 'main'
feat: async upload of medias

See merge request veretcle/oolatoocs!3
2023-11-16 08:34:56 +00:00
VC
b73d6340c9 feat: async upload of medias 2023-11-15 15:20:03 +01:00
VC
00ba8bda42 Merge branch 'feat_other_medias' into 'main'
feat: separate metadata create + modify upload media for simple media only

See merge request veretcle/oolatoocs!2
2023-11-11 12:16:32 +00:00
VC
6d208f3de3 tamerelol 2023-11-11 12:59:57 +01:00
VC
b0c9485c82 Merge branch 'main' into 'feat_other_medias'
# Conflicts:
#   Cargo.lock
#   Cargo.toml
#   src/lib.rs
#   src/twitter.rs
2023-11-11 11:40:57 +00:00
VC
af7156786b chore: bump version to v1.0.0 2023-11-11 12:16:57 +01:00
VC
b9179d8cce feat: threads 2023-11-11 12:16:18 +01:00
VC
eba13ba095 feat: add video/gif medias 2023-11-10 19:29:29 +01:00
VC
f4c0504c28 Merge branch 'feat_medias' into 'main'
feat: attach medias to tweets

See merge request veretcle/oolatoocs!1
2023-11-09 17:52:56 +00:00
VC
dc9ed538f4 feat: attach medias to tweets 2023-11-09 18:44:31 +01:00
9 changed files with 882 additions and 560 deletions

703
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +1,23 @@
[package]
name = "oolatoocs"
version = "0.1.0"
version = "1.4.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
clap = "^4"
dissolve = "0.2.2"
env_logger = "^0.10"
futures = "^0.3"
html-escape = "^0.2"
log = "^0.4"
megalodon = "^0.11"
oauth1-request = "^0.6"
regex = "1.10.2"
reqwest = { version = "0.11.22", features = ["json"] }
regex = "^1.10"
reqwest = { version = "^0.11", features = ["json", "stream", "multipart"] }
rusqlite = "^0.27"
serde = { version = "^1.0", features = ["derive"] }
tokio = { version = "^1.33", features = ["rt-multi-thread", "macros"] }
tokio = { version = "^1.33", features = ["rt-multi-thread", "macros", "time"] }
toml = "^0.8"
[profile.release]

79
README.md Normal file
View File

@@ -0,0 +1,79 @@
# 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 couldnt 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.
# Remarkable features
What it can do:
* Reproduces the Toot content into the Tweet;
* Cuts (poorly) the Toot in half in its too long for Twitter 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
* Can reproduce threads from Mastodon to Twitter
* Can prevent a Toot from being tweeted by using the #NoTweet (case-insensitive) hashtag in Mastodon
What it cant do:
* Poll (no idea on how to do it)
# 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] # youll 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>"
```
## 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?
Youll 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 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, youll put it an cron (from a non-root user), with the default path for config file and let it do its job. Yeah, thats it.

View File

@@ -8,7 +8,7 @@ pub struct Config {
pub twitter: TwitterConfig,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, Clone)]
pub struct TwitterConfig {
pub consumer_key: String,
pub consumer_secret: String,

25
src/error.rs Normal file
View File

@@ -0,0 +1,25 @@
use std::{
error::Error,
fmt::{Display, Formatter, Result},
};
#[derive(Debug)]
pub struct OolatoocsError {
details: String,
}
impl OolatoocsError {
pub fn new(msg: &str) -> OolatoocsError {
OolatoocsError {
details: msg.to_string(),
}
}
}
impl Error for OolatoocsError {}
impl Display for OolatoocsError {
fn fmt(&self, f: &mut Formatter) -> Result {
write!(f, "{}", self.details)
}
}

View File

@@ -1,3 +1,6 @@
mod error;
pub use error::OolatoocsError;
mod config;
pub use config::{parse_toml, Config};
@@ -11,11 +14,11 @@ use mastodon::get_mastodon_timeline_since;
pub use mastodon::register;
mod utils;
use utils::strip_everything;
use utils::{generate_multi_tweets, strip_everything};
mod twitter;
#[allow(unused_imports)]
use twitter::{post_tweet, upload_media};
use twitter::{generate_media_ids, post_tweet};
use rusqlite::Connection;
@@ -33,15 +36,42 @@ pub async fn run(config: &Config) {
.unwrap_or_else(|e| panic!("Cannot get instance: {}", e));
for toot in timeline {
let Ok(tweet_content) = strip_everything(&toot.content, &toot.tags) else {
// 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 cant strip something
};
// if we wanted to cut toot in half, now would be the right time to do so
// treating medias (nothing for now)
let tweet_id = post_tweet(&config.twitter, &tweet_content, &[])
// threads if necessary
let mut 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)
});
// 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;
let reply_id = post_tweet(&config.twitter, &first_half, &[], &reply_to)
.await
.unwrap_or_else(|e| panic!("Cannot post the first half of {}: {}", &toot.id, e));
reply_to = Some(reply_id);
};
// treats medias
let 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)
.await
.unwrap_or_else(|e| panic!("Cannot Tweet {}: {}", toot.id, e));
// writes the current state of the tweet
write_state(
&conn,
TweetToToot {

View File

@@ -34,7 +34,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 +44,7 @@ pub async fn get_mastodon_timeline_since(
.filter(|t| t.visibility == StatusVisibility::Public) // excludes everything that isnt
// public
.filter(|t| t.reblog.is_none()) // excludes reblogs
.cloned()
.collect();
timeline.reverse();

View File

@@ -1,26 +1,94 @@
use crate::config::TwitterConfig;
use crate::error::OolatoocsError;
use futures::{stream, StreamExt};
use log::{debug, error, warn};
use megalodon::entities::attachment::{Attachment, AttachmentType};
use oauth1_request::Token;
use reqwest::Client;
use reqwest::{
multipart::{Form, Part},
Body, Client,
};
use serde::{Deserialize, Serialize};
use std::error::Error;
use std::{error::Error, ops::Not};
use tokio::time::{sleep, Duration};
/// I dont know, dont ask me
const TWITTER_API_TWEET_URL: &str = "https://api.twitter.com/2/tweets";
const TWITTER_UPLOAD_MEDIA_URL: &str = "https://upload.twitter.com/1.1/media/upload.json";
const TWITTER_METADATA_MEDIA_URL: &str =
"https://upload.twitter.com/1.1/media/metadata/create.json";
// I dont know, dont ask me
#[derive(oauth1_request::Request)]
struct EmptyRequest {}
#[derive(Serialize, Debug)]
pub struct Tweet {
pub text: String,
struct Tweet {
text: String,
#[serde(skip_serializing_if = "Option::is_none")]
media: Option<TweetMediasIds>,
#[serde(skip_serializing_if = "Option::is_none")]
reply: Option<TweetReply>,
}
#[derive(Serialize, Debug)]
struct TweetMediasIds {
media_ids: Vec<String>,
}
#[derive(Serialize, Debug)]
struct TweetReply {
in_reply_to_tweet_id: String,
}
#[derive(Deserialize, Debug)]
pub struct TweetResponse {
pub data: TweetResponseData,
struct TweetResponse {
data: TweetResponseData,
}
#[derive(Deserialize, Debug)]
pub struct TweetResponseData {
pub id: String,
struct TweetResponseData {
id: String,
}
#[derive(Deserialize, Debug)]
struct UploadMediaResponse {
media_id: u64,
processing_info: Option<UploadMediaResponseProcessingInfo>,
}
#[derive(Deserialize, Debug)]
struct UploadMediaResponseProcessingInfo {
state: UploadMediaResponseProcessingInfoState,
check_after_secs: Option<u64>,
}
#[derive(Deserialize, Debug)]
enum UploadMediaResponseProcessingInfoState {
#[serde(rename = "failed")]
Failed,
#[serde(rename = "succeeded")]
Succeeded,
#[serde(rename = "pending")]
Pending,
#[serde(rename = "in_progress")]
InProgress,
}
#[derive(Serialize, Debug)]
struct MediaMetadata {
media_id: u64,
alt_text: MediaMetadataAltText,
}
#[derive(Serialize, Debug)]
struct MediaMetadataAltText {
text: String,
}
#[derive(Serialize, Debug, oauth1_request::Request)]
struct UploadMediaCommand {
command: String,
media_id: String,
}
/// This function returns the OAuth1 Token object from TwitterConfig
@@ -33,32 +101,352 @@ fn get_token(config: &TwitterConfig) -> Token {
)
}
/// This function uploads media from Mastodon to Twitter and returns the media id from Twitter
#[allow(dead_code)]
pub async fn upload_media(_u: &str) -> Result<u64, Box<dyn Error>> {
Ok(0)
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
async fn upload_simple_media(
config: &TwitterConfig,
u: &str,
d: &Option<String>,
) -> Result<u64, Box<dyn Error + Send + Sync>> {
// initiate request parameters
let empty_request = EmptyRequest {}; // Why? Because fuck you, thats why!
let token = get_token(config);
// retrieve the length and bytes stream from the given URL
let dl = reqwest::get(u).await?;
let content_length = dl
.content_length()
.ok_or(format!("Cannot get content length for {}", u))?;
let stream = dl.bytes_stream();
debug!("Ref download URL: {}", u);
// upload the media
let client = Client::new();
let res = client
.post(TWITTER_UPLOAD_MEDIA_URL)
.header(
"Authorization",
oauth1_request::post(
TWITTER_UPLOAD_MEDIA_URL,
&empty_request,
&token,
oauth1_request::HMAC_SHA1,
),
)
.multipart(Form::new().part(
"media",
Part::stream_with_length(Body::wrap_stream(stream), content_length),
))
.send()
.await?
.json::<UploadMediaResponse>()
.await?;
debug!("Media ID: {}", res.media_id);
// update the metadata
if let Some(metadata) = d {
debug!("Metadata found! Processing…");
metadata_create(config, res.media_id, metadata).await?;
}
Ok(res.media_id)
}
/// 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 + Send + Sync>> {
let token = get_token(config);
let empty_request = EmptyRequest {};
let media_metadata = MediaMetadata {
media_id: id,
alt_text: MediaMetadataAltText {
text: m.to_string(),
},
};
debug!("Metadata to process: {}", m);
let client = Client::new();
let metadata = client
.post(TWITTER_METADATA_MEDIA_URL)
.header(
"Authorization",
oauth1_request::post(
TWITTER_METADATA_MEDIA_URL,
&empty_request,
&token,
oauth1_request::HMAC_SHA1,
),
)
.json(&media_metadata)
.send()
.await?;
debug!("Metadata processed with return code: {}", metadata.status());
Ok(())
}
/// This posts video/gif to Twitter and returns the media id from Twitter
async fn upload_chunk_media(
config: &TwitterConfig,
u: &str,
t: &str,
) -> Result<u64, Box<dyn Error + Send + Sync>> {
let empty_request = EmptyRequest {};
let token = get_token(config);
// retrieve the length, type and bytes stream from the given URL
let mut dl = reqwest::get(u).await?;
let content_length = dl
.content_length()
.ok_or(format!("Cannot get content length for {}", u))?;
let content_headers = dl.headers().clone();
let content_type = content_headers
.get("Content-Type")
.ok_or(format!("Cannot get content type for {}", u))?
.to_str()?;
debug!("Init the slot for uploading media: {}", u);
// init the slot for uploading
let client = Client::new();
let orig_media_id = client
.post(TWITTER_UPLOAD_MEDIA_URL)
.header(
"Authorization",
oauth1_request::post(
TWITTER_UPLOAD_MEDIA_URL,
&empty_request,
&token,
oauth1_request::HMAC_SHA1,
),
)
.multipart(
Form::new()
.text("command", "INIT")
.text("media_type", content_type.to_owned())
.text("total_bytes", content_length.to_string())
.text("media_category", t.to_string()),
)
.send()
.await?
.json::<UploadMediaResponse>()
.await?;
debug!("Slot initiated with ID: {}", orig_media_id.media_id);
debug!("Appending media to ID: {}", orig_media_id.media_id);
// append the media to the corresponding slot
let mut segment: u8 = 0;
while let Some(chunk) = dl.chunk().await? {
debug!(
"Appending segment {} for media ID {}",
segment, orig_media_id.media_id
);
let chunk_size: u64 = chunk.len().try_into().unwrap();
let res = client
.post(TWITTER_UPLOAD_MEDIA_URL)
.header(
"Authorization",
oauth1_request::post(
TWITTER_UPLOAD_MEDIA_URL,
&empty_request,
&token,
oauth1_request::HMAC_SHA1,
),
)
.multipart(
Form::new()
.text("command", "APPEND")
.text("media_id", orig_media_id.media_id.to_string())
.text("segment_index", segment.to_string())
.part("media", Part::stream_with_length(chunk, chunk_size)),
)
.send()
.await?;
if !res.status().is_success() {
return Err(
OolatoocsError::new(&format!("Cannot upload part {} of {}", segment, u)).into(),
);
}
segment += 1;
}
debug!("Finalize media ID: {}", orig_media_id.media_id);
// Finalizing task
let fin = client
.post(TWITTER_UPLOAD_MEDIA_URL)
.header(
"Authorization",
oauth1_request::post(
TWITTER_UPLOAD_MEDIA_URL,
&empty_request,
&token,
oauth1_request::HMAC_SHA1,
),
)
.multipart(
Form::new()
.text("command", "FINALIZE")
.text("media_id", orig_media_id.media_id.to_string()),
)
.send()
.await?
.json::<UploadMediaResponse>()
.await?;
if let Some(p_info) = fin.processing_info {
if let Some(wait_sec) = p_info.check_after_secs {
debug!(
"Processing is not finished yet for ID {}, waiting {} secs",
orig_media_id.media_id, wait_sec
);
// getting here, we have a status and a check_after_secs
// this status can be anything but we will check it afterwards
// whatever happens, we can wait here before proceeding
sleep(Duration::from_secs(wait_sec)).await;
let command = UploadMediaCommand {
command: "STATUS".to_string(),
media_id: orig_media_id.media_id.to_string(),
};
loop {
debug!(
"Checking on status for ID {} after waiting {} secs",
orig_media_id.media_id, wait_sec
);
let status = client
.get(TWITTER_UPLOAD_MEDIA_URL)
.header(
"Authorization",
oauth1_request::get(
TWITTER_UPLOAD_MEDIA_URL,
&command,
&token,
oauth1_request::HMAC_SHA1,
),
)
.query(&command)
.send()
.await?
.json::<UploadMediaResponse>()
.await?;
let p_status = status.processing_info.unwrap(); // shouldnt be None at this point
match p_status.state {
UploadMediaResponseProcessingInfoState::Failed => {
debug!("Processing has failed!");
return Err(OolatoocsError::new(&format!(
"Upload for {} (id: {}) has failed",
u, orig_media_id.media_id
))
.into());
}
UploadMediaResponseProcessingInfoState::Succeeded => {
debug!("Processing has succeeded, exiting loop!");
break;
}
UploadMediaResponseProcessingInfoState::Pending
| UploadMediaResponseProcessingInfoState::InProgress => {
debug!(
"Processing still pending, waiting {} secs more…",
p_status.check_after_secs.unwrap() // unwrap is safe here,
// check_after_secs is only present
// when status is pending or in
// progress
);
sleep(Duration::from_secs(p_status.check_after_secs.unwrap())).await;
continue;
}
}
}
}
}
Ok(orig_media_id.media_id)
}
/// This posts Tweets with all the associated medias
pub async fn post_tweet(
config: &TwitterConfig,
content: &str,
_medias: &[u64],
medias: &[u64],
reply_to: &Option<u64>,
) -> Result<u64, Box<dyn Error>> {
let uri = "https://api.twitter.com/2/tweets";
let empty_request = EmptyRequest {}; // Why? Because fuck you, thats why!
let token = get_token(config);
let tweet = Tweet {
text: content.to_string(),
media: medias.is_empty().not().then(|| TweetMediasIds {
media_ids: medias.iter().map(|m| m.to_string()).collect(),
}),
reply: reply_to.map(|s| TweetReply {
in_reply_to_tweet_id: s.to_string(),
}),
};
let client = Client::new();
let res = client
.post(uri)
.post(TWITTER_API_TWEET_URL)
.header(
"Authorization",
oauth1_request::post(uri, &empty_request, &token, oauth1_request::HMAC_SHA1),
oauth1_request::post(
TWITTER_API_TWEET_URL,
&empty_request,
&token,
oauth1_request::HMAC_SHA1,
),
)
.json(&tweet)
.send()

View File

@@ -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 dont 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 doesnt count words the same we do, so youll have to improvise
fn twitter_count(content: &str) -> usize {
let mut count = 0;
let split_content = content.split(' ');
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,109 @@ 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);
}
#[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 =
"Cest super ça! #putaclic #TAMERELOL #JeFaisNawakEnCamelCase".to_string();
let sample = "Cest 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&#39;est le Dojobar ! Au programme ce soir, une rétrospective sur la série Mario &amp; Luigi.<br />Comme d&#39;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&#39;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);
}
}