mirror of
https://framagit.org/veretcle/oolatoocs.git
synced 2025-07-20 20:41:17 +02:00
Compare commits
16 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
b1aed34f3c | ||
![]() |
e8bde4c779 | ||
![]() |
80946ac131 | ||
![]() |
87b0567b59 | ||
![]() |
b6f87e829f | ||
![]() |
6fccbf8d16 | ||
![]() |
1fdea7f69d | ||
![]() |
b73d6340c9 | ||
![]() |
00ba8bda42 | ||
![]() |
6d208f3de3 | ||
![]() |
b0c9485c82 | ||
![]() |
af7156786b | ||
![]() |
b9179d8cce | ||
![]() |
eba13ba095 | ||
![]() |
f4c0504c28 | ||
![]() |
dc9ed538f4 |
703
Cargo.lock
generated
703
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
11
Cargo.toml
11
Cargo.toml
@@ -1,22 +1,23 @@
|
||||
[package]
|
||||
name = "oolatoocs"
|
||||
version = "0.1.0"
|
||||
version = "1.3.1"
|
||||
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]
|
||||
|
@@ -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
25
src/error.rs
Normal 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)
|
||||
}
|
||||
}
|
36
src/lib.rs
36
src/lib.rs
@@ -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,36 @@ 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 {
|
||||
let Ok(mut tweet_content) = strip_everything(&toot.content, &toot.tags) else {
|
||||
continue; // skip in case we can’t 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 {
|
||||
|
@@ -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 isn’t
|
||||
// public
|
||||
.filter(|t| t.reblog.is_none()) // excludes reblogs
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
timeline.reverse();
|
||||
|
422
src/twitter.rs
422
src/twitter.rs
@@ -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 don’t know, don’t 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 don’t know, don’t 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, that’s 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(); // shouldn’t 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, that’s 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()
|
||||
|
156
src/utils.rs
156
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(' ');
|
||||
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 =
|
||||
"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