mirror of
https://framagit.org/veretcle/oolatoocs.git
synced 2025-12-06 14:53:15 +01:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
498095d3a8 | ||
|
|
5ee64014eb | ||
|
|
639582ba59 | ||
|
|
43ca862d5a | ||
|
|
47d7fdbd42 | ||
|
|
7334fb3d09 | ||
|
|
79ac915347 | ||
|
|
e89e6e51ec | ||
|
|
7b21a0e3a7 | ||
|
|
43aa6dcd99 | ||
|
|
cf5fe11b56 | ||
|
|
7bd0843cf6 | ||
|
|
402fcffc75 | ||
|
|
b295cc5b94 | ||
|
|
a882aaa59d | ||
|
|
259032a7b9 | ||
|
|
e7f0c9c6f5 | ||
|
|
83c8da46e8 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,3 +2,4 @@
|
|||||||
.last_tweet
|
.last_tweet
|
||||||
.config.toml
|
.config.toml
|
||||||
.config.json
|
.config.json
|
||||||
|
.bsky.json
|
||||||
|
|||||||
1893
Cargo.lock
generated
1893
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "oolatoocs"
|
name = "oolatoocs"
|
||||||
version = "4.2.2"
|
version = "4.5.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
@@ -12,7 +12,6 @@ env_logger = "^0.11"
|
|||||||
futures = "^0.3"
|
futures = "^0.3"
|
||||||
html-escape = "^0.2"
|
html-escape = "^0.2"
|
||||||
log = "^0.4"
|
log = "^0.4"
|
||||||
megalodon = "^1.0"
|
|
||||||
oauth1-request = "^0.6"
|
oauth1-request = "^0.6"
|
||||||
regex = "^1.10"
|
regex = "^1.10"
|
||||||
reqwest = { version = "^0.12", features = ["json", "stream", "multipart"] }
|
reqwest = { version = "^0.12", features = ["json", "stream", "multipart"] }
|
||||||
@@ -24,6 +23,7 @@ bsky-sdk = "^0.1"
|
|||||||
atrium-api = { version = "^0.25", features = ["namespace-appbsky"] }
|
atrium-api = { version = "^0.25", features = ["namespace-appbsky"] }
|
||||||
image = "^0.25"
|
image = "^0.25"
|
||||||
webp = "^0.3"
|
webp = "^0.3"
|
||||||
|
megalodon = "^1.1"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
strip = true
|
strip = true
|
||||||
|
|||||||
@@ -16,10 +16,13 @@ Since 2025-01-20, Twitter is now longer supported.
|
|||||||
What it can do:
|
What it can do:
|
||||||
* Reproduces the Toot content into the Record;
|
* Reproduces the Toot content into the Record;
|
||||||
* Cuts (poorly) the Toot in half in it’s too long for Bluesky and thread it (this is cut using a word count, not the best method, but it gets the job done);
|
* Cuts (poorly) the Toot in half in it’s too long for 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 Bluesky
|
* Reuploads images/gifs/videos/webcards from Mastodon to Bluesky
|
||||||
* ⚠️ Bluesky does not support mixing images and videos. You can have up to 4 images on a Bsky record **or** 1 video but not mix around. If you do so, only the video will be posted on Bluesky.
|
* ⚠️ Bluesky does not support mixing images and videos. You can have up to 4 images on a Bsky record **or** 1 video but not mix around. If you do so, only the video will be posted on Bluesky.
|
||||||
* ⚠️ Bluesky does not support images greater than 1Mb (that is 1,000,000,000 bytes or 976.6 KiB). I might incorporate soon a image quality reducer or WebP transcoding to avoid this issue.
|
* ⚠️ Bluesky does not support images greater than 1Mb (that is 1,000,000 bytes or 976.6 KiB), so Oolatoocs converts the image to WebP and progressively reduces the quality to fit that limitation.
|
||||||
|
* ⚠️ Bluesky does not support webcards with any other media/quote, so webcards have the last priority
|
||||||
* Can reproduce threads from Mastodon to Bluesky
|
* Can reproduce threads from Mastodon to Bluesky
|
||||||
|
* Can reproduce (self-)quotes from Mastodon to Bluesky
|
||||||
|
* ⚠️ Bluesky can’t do quotes with webcards, you can only embed images **or** a video with quotes
|
||||||
* ⚠️ Bluesky does support polls for now. So the poll itself is just presented as text from Mastodon instead which is not the most elegant.
|
* ⚠️ Bluesky does support polls for now. So the poll itself is just presented as text from Mastodon instead which is not the most elegant.
|
||||||
* Can prevent a Toot from being recorded to Bluesky by using the #NoTweet (case-insensitive) hashtag in Mastodon
|
* Can prevent a Toot from being recorded to Bluesky by using the #NoTweet (case-insensitive) hashtag in Mastodon
|
||||||
|
|
||||||
@@ -30,6 +33,7 @@ The configuration is relatively easy to follow:
|
|||||||
```toml
|
```toml
|
||||||
[oolatoocs]
|
[oolatoocs]
|
||||||
db_path = "/var/lib/oolatoocs/db.sqlite3" # the path to the DB where toots/tweets/records are stored
|
db_path = "/var/lib/oolatoocs/db.sqlite3" # the path to the DB where toots/tweets/records are stored
|
||||||
|
remove_hashtags = false # optional, default to false
|
||||||
|
|
||||||
[mastodon] # This part can be generated, see below
|
[mastodon] # This part can be generated, see below
|
||||||
base = "https://m.nintendojo.fr"
|
base = "https://m.nintendojo.fr"
|
||||||
|
|||||||
222
src/bsky.rs
222
src/bsky.rs
@@ -1,4 +1,4 @@
|
|||||||
use crate::config::BlueskyConfig;
|
use crate::{config::BlueskyConfig, utils::convert_aspect_ratio, OolatoocsError};
|
||||||
use atrium_api::{
|
use atrium_api::{
|
||||||
app::bsky::feed::post::RecordData, com::atproto::repo::upload_blob::Output,
|
app::bsky::feed::post::RecordData, com::atproto::repo::upload_blob::Output,
|
||||||
types::string::Datetime, types::string::Language, types::string::RecordKey,
|
types::string::Datetime, types::string::Language, types::string::RecordKey,
|
||||||
@@ -148,23 +148,127 @@ async fn get_record(
|
|||||||
Ok(record)
|
Ok(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate an embed card record into Bsky
|
/// Generate a Union of embed records to be built-in into records
|
||||||
/// If the preview image does not exist or fails to upload, it is simply ignored
|
/// In case an embed cannot be uploaded/created, this calling function silently gets Option instead
|
||||||
|
/// of failing
|
||||||
pub async fn generate_embed_records(
|
pub async fn generate_embed_records(
|
||||||
|
config: &BlueskyConfig,
|
||||||
bsky: &BskyAgent,
|
bsky: &BskyAgent,
|
||||||
card: &Card,
|
qid: Option<&str>,
|
||||||
) -> Option<atrium_api::types::Union<atrium_api::app::bsky::feed::post::RecordEmbedRefs>> {
|
media_attach: &[Attachment],
|
||||||
// uploads the image card, if it fails, simply ignore everything
|
card: &Option<Card>,
|
||||||
let blob = if let Some(url) = &card.image {
|
) -> Result<
|
||||||
if let Ok(image_blob) = upload_media(true, bsky, url).await {
|
Option<atrium_api::types::Union<atrium_api::app::bsky::feed::post::RecordEmbedRefs>>,
|
||||||
Some(image_blob.blob.clone())
|
Box<dyn Error + Send + Sync>,
|
||||||
|
> {
|
||||||
|
// handle quote if any
|
||||||
|
let quote_embed = match qid {
|
||||||
|
Some(q) => generate_quote_records(config, q).await.ok(),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// handle medias if any
|
||||||
|
let media_embed = if media_attach.len() > usize::from(0u8) {
|
||||||
|
let image_media_attach: Vec<_> = media_attach
|
||||||
|
.iter()
|
||||||
|
.filter(|x| x.r#type == AttachmentType::Image)
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
let video_media_attach: Vec<_> = media_attach
|
||||||
|
.iter()
|
||||||
|
.filter(|x| x.r#type == AttachmentType::Video || x.r#type == AttachmentType::Gifv)
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !video_media_attach.is_empty() {
|
||||||
|
generate_video_record(bsky, video_media_attach).await.ok()
|
||||||
|
} else if !image_media_attach.is_empty() {
|
||||||
|
generate_images_records(bsky, image_media_attach).await.ok()
|
||||||
} else {
|
} else {
|
||||||
None
|
return Err(OolatoocsError::new("A media attached is not an image nor a video").into());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// handle webcard if any
|
||||||
|
let webcard_embed = match card {
|
||||||
|
Some(t) => generate_webcard_records(bsky, t).await.ok(),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(q) = quote_embed {
|
||||||
|
if let Some(m) = media_embed {
|
||||||
|
let medias_mapped = match m {
|
||||||
|
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedImagesMain(a) => atrium_api::app::bsky::embed::record_with_media::MainMediaRefs::AppBskyEmbedImagesMain(a),
|
||||||
|
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedVideoMain(a) => atrium_api::app::bsky::embed::record_with_media::MainMediaRefs::AppBskyEmbedVideoMain(a),
|
||||||
|
_ => return Err(OolatoocsError::new("Something went terribly wrong when trying to add image/video to quote record: can’t decapsulate media").into()),
|
||||||
|
};
|
||||||
|
let quote_mapped = match q {
|
||||||
|
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedRecordMain(
|
||||||
|
a,
|
||||||
|
) => a,
|
||||||
|
_ => return Err(OolatoocsError::new("Something went terribly wrong when trying to add image/video to quote record: can’t decapsulate quote").into()),
|
||||||
|
};
|
||||||
|
Some(atrium_api::types::Union::Refs(
|
||||||
|
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedRecordWithMediaMain(
|
||||||
|
Box::new(
|
||||||
|
atrium_api::app::bsky::embed::record_with_media::MainData {
|
||||||
|
media: atrium_api::types::Union::Refs(medias_mapped),
|
||||||
|
record: (*quote_mapped),
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Some(atrium_api::types::Union::Refs(q))
|
||||||
|
}
|
||||||
|
} else if media_embed.is_some() {
|
||||||
|
media_embed.map(atrium_api::types::Union::Refs)
|
||||||
|
} else if webcard_embed.is_some() {
|
||||||
|
webcard_embed.map(atrium_api::types::Union::Refs)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate an quote embed record
|
||||||
|
/// it is encapsulated in Option to prevent this function from failing
|
||||||
|
async fn generate_quote_records(
|
||||||
|
config: &BlueskyConfig,
|
||||||
|
quote_id: &str,
|
||||||
|
) -> Result<atrium_api::app::bsky::feed::post::RecordEmbedRefs, Box<dyn Error>> {
|
||||||
|
// if we can’t match the quote_id, simply return None
|
||||||
|
let quote_record = get_record(&config.handle, &rkey(quote_id)).await?;
|
||||||
|
|
||||||
|
Ok(
|
||||||
|
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedRecordMain(Box::new(
|
||||||
|
atrium_api::app::bsky::embed::record::MainData {
|
||||||
|
record: atrium_api::com::atproto::repo::strong_ref::MainData {
|
||||||
|
cid: quote_record.data.cid.unwrap(),
|
||||||
|
uri: quote_record.data.uri.to_owned(),
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate an embed webcard record into Bsky
|
||||||
|
/// If the preview image does not exist or fails to upload, it is simply ignored
|
||||||
|
async fn generate_webcard_records(
|
||||||
|
bsky: &BskyAgent,
|
||||||
|
card: &Card,
|
||||||
|
) -> Result<atrium_api::app::bsky::feed::post::RecordEmbedRefs, Box<dyn Error + Send + Sync>> {
|
||||||
|
let blob = match &card.image {
|
||||||
|
Some(url) => upload_media(true, bsky, url).await?.blob.clone().into(),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
let record_card = atrium_api::app::bsky::embed::external::ExternalData {
|
let record_card = atrium_api::app::bsky::embed::external::ExternalData {
|
||||||
description: card.description.clone(),
|
description: card.description.clone(),
|
||||||
thumb: blob,
|
thumb: blob,
|
||||||
@@ -172,61 +276,22 @@ pub async fn generate_embed_records(
|
|||||||
uri: card.url.clone(),
|
uri: card.url.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
Some(atrium_api::types::Union::Refs(
|
Ok(
|
||||||
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedExternalMain(Box::new(
|
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedExternalMain(Box::new(
|
||||||
atrium_api::app::bsky::embed::external::MainData {
|
atrium_api::app::bsky::embed::external::MainData {
|
||||||
external: record_card.into(),
|
external: record_card.into(),
|
||||||
}
|
}
|
||||||
.into(),
|
.into(),
|
||||||
)),
|
)),
|
||||||
))
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate an array of Bsky media records
|
/// Generate an array of Bsky image media records
|
||||||
/// As Bsky does not support multiple video in a record or mix of video and images, video has the
|
async fn generate_images_records(
|
||||||
/// highest priority
|
|
||||||
pub async fn generate_media_records(
|
|
||||||
bsky: &BskyAgent,
|
bsky: &BskyAgent,
|
||||||
media_attach: &[Attachment],
|
media_attach: Vec<Attachment>,
|
||||||
) -> Option<atrium_api::types::Union<atrium_api::app::bsky::feed::post::RecordEmbedRefs>> {
|
) -> Result<atrium_api::app::bsky::feed::post::RecordEmbedRefs, Box<dyn Error + Send + Sync>> {
|
||||||
let mut embed: Option<
|
let mut stream = stream::iter(media_attach)
|
||||||
atrium_api::types::Union<atrium_api::app::bsky::feed::post::RecordEmbedRefs>,
|
|
||||||
> = None;
|
|
||||||
|
|
||||||
let image_media_attach: Vec<_> = media_attach
|
|
||||||
.iter()
|
|
||||||
.filter(|x| x.r#type == AttachmentType::Image)
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
let video_media_attach: Vec<_> = media_attach
|
|
||||||
.iter()
|
|
||||||
.filter(|x| (x.r#type == AttachmentType::Video || x.r#type == AttachmentType::Gifv))
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Bsky only tasks 1 video per post, so we’ll try to treat that first and exit
|
|
||||||
if !video_media_attach.is_empty() {
|
|
||||||
// treat only the very first video, ignore the rest
|
|
||||||
let media = &video_media_attach[0];
|
|
||||||
let blob = upload_media(false, bsky, &media.url).await.unwrap();
|
|
||||||
|
|
||||||
embed = Some(atrium_api::types::Union::Refs(
|
|
||||||
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedVideoMain(Box::new(
|
|
||||||
atrium_api::app::bsky::embed::video::MainData {
|
|
||||||
alt: media.description.clone(),
|
|
||||||
aspect_ratio: None,
|
|
||||||
captions: None,
|
|
||||||
video: blob.data.blob,
|
|
||||||
}
|
|
||||||
.into(),
|
|
||||||
)),
|
|
||||||
));
|
|
||||||
|
|
||||||
// returns immediately, we don’t want to treat the other medias
|
|
||||||
return embed;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut stream = stream::iter(image_media_attach)
|
|
||||||
.map(|media| {
|
.map(|media| {
|
||||||
let bsky = bsky.clone();
|
let bsky = bsky.clone();
|
||||||
tokio::task::spawn(async move {
|
tokio::task::spawn(async move {
|
||||||
@@ -237,7 +302,9 @@ pub async fn generate_media_records(
|
|||||||
.description
|
.description
|
||||||
.clone()
|
.clone()
|
||||||
.map_or("".to_string(), |v| v.to_owned()),
|
.map_or("".to_string(), |v| v.to_owned()),
|
||||||
aspect_ratio: None,
|
aspect_ratio: convert_aspect_ratio(
|
||||||
|
&media.meta.as_ref().and_then(|m| m.original.clone()),
|
||||||
|
),
|
||||||
image: i.data.blob,
|
image: i.data.blob,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -256,14 +323,38 @@ pub async fn generate_media_records(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !images.is_empty() {
|
if !images.is_empty() {
|
||||||
embed = Some(atrium_api::types::Union::Refs(
|
return Ok(
|
||||||
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedImagesMain(Box::new(
|
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedImagesMain(Box::new(
|
||||||
atrium_api::app::bsky::embed::images::MainData { images }.into(),
|
atrium_api::app::bsky::embed::images::MainData { images }.into(),
|
||||||
)),
|
)),
|
||||||
));
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
embed
|
Err(OolatoocsError::new("Cannot embed media").into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a video Bsky media record
|
||||||
|
async fn generate_video_record(
|
||||||
|
bsky: &BskyAgent,
|
||||||
|
media_attach: Vec<Attachment>,
|
||||||
|
) -> Result<atrium_api::app::bsky::feed::post::RecordEmbedRefs, Box<dyn Error + Send + Sync>> {
|
||||||
|
// treat only the very first video, ignore the rest
|
||||||
|
let media = &media_attach[0];
|
||||||
|
let blob = upload_media(false, bsky, &media.url).await?;
|
||||||
|
|
||||||
|
Ok(
|
||||||
|
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedVideoMain(Box::new(
|
||||||
|
atrium_api::app::bsky::embed::video::MainData {
|
||||||
|
alt: media.description.clone(),
|
||||||
|
aspect_ratio: convert_aspect_ratio(
|
||||||
|
&media.meta.as_ref().and_then(|m| m.original.clone()),
|
||||||
|
),
|
||||||
|
captions: None,
|
||||||
|
video: blob.data.blob,
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn upload_media(
|
async fn upload_media(
|
||||||
@@ -278,11 +369,20 @@ async fn upload_media(
|
|||||||
} else {
|
} else {
|
||||||
// this is an image and it’s over 1Mb long
|
// this is an image and it’s over 1Mb long
|
||||||
debug!("Img file too large: {}", content_length);
|
debug!("Img file too large: {}", content_length);
|
||||||
|
// defaults to 95% quality for WebP compression
|
||||||
|
let mut default_quality = 95f32;
|
||||||
let img = ImageReader::new(Cursor::new(dl.bytes().await?))
|
let img = ImageReader::new(Cursor::new(dl.bytes().await?))
|
||||||
.with_guessed_format()?
|
.with_guessed_format()?
|
||||||
.decode()?;
|
.decode()?;
|
||||||
let encoder: Encoder = Encoder::from_image(&img)?;
|
let encoder: Encoder = Encoder::from_image(&img)?;
|
||||||
let webp: WebPMemory = encoder.encode(90f32);
|
let mut webp: WebPMemory = encoder.encode(default_quality);
|
||||||
|
|
||||||
|
while webp.len() > 1_000_000 {
|
||||||
|
debug!("Img file too large at {}%, reducing…", default_quality);
|
||||||
|
default_quality -= 5.0;
|
||||||
|
webp = encoder.encode(default_quality);
|
||||||
|
}
|
||||||
|
|
||||||
webp.to_vec()
|
webp.to_vec()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,17 @@ pub struct Config {
|
|||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct OolatoocsConfig {
|
pub struct OolatoocsConfig {
|
||||||
pub db_path: String,
|
pub db_path: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub remove_hashtags: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for OolatoocsConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
OolatoocsConfig {
|
||||||
|
db_path: "/var/lib/oolatoocs/db".to_string(),
|
||||||
|
remove_hashtags: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|||||||
38
src/lib.rs
38
src/lib.rs
@@ -18,9 +18,7 @@ mod utils;
|
|||||||
use utils::{generate_multi_tweets, strip_everything};
|
use utils::{generate_multi_tweets, strip_everything};
|
||||||
|
|
||||||
mod bsky;
|
mod bsky;
|
||||||
use bsky::{
|
use bsky::{build_post_record, generate_embed_records, get_session, BskyReply};
|
||||||
build_post_record, generate_embed_records, generate_media_records, get_session, BskyReply,
|
|
||||||
};
|
|
||||||
|
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
|
|
||||||
@@ -88,7 +86,14 @@ pub async fn run(config: &Config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// form tweet_content and strip everything useless in it
|
// form tweet_content and strip everything useless in it
|
||||||
let Ok(mut tweet_content) = strip_everything(&toot.content, &toot.tags) else {
|
let toot_tags: Vec<megalodon::entities::status::Tag> =
|
||||||
|
match &config.oolatoocs.remove_hashtags {
|
||||||
|
true => toot.tags.clone(),
|
||||||
|
false => vec![],
|
||||||
|
};
|
||||||
|
let Ok(mut tweet_content) =
|
||||||
|
strip_everything(&toot.content, &toot_tags, &config.mastodon.base)
|
||||||
|
else {
|
||||||
continue; // skip in case we can’t strip something
|
continue; // skip in case we can’t strip something
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -153,15 +158,24 @@ pub async fn run(config: &Config) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// treats medias
|
// get quote_id if any
|
||||||
let mut record_embed = generate_media_records(&bluesky, &toot.media_attachments).await;
|
let quote_id = match toot.reblog {
|
||||||
|
Some(r) => match read_state(&conn, Some(r.id.parse::<u64>().unwrap())) {
|
||||||
|
Ok(q) => q.map(|x| x.record_uri.to_owned()),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
// treats embed cards if any
|
let record_embed = generate_embed_records(
|
||||||
if let Some(card) = &toot.card {
|
&config.bluesky,
|
||||||
if record_embed.is_none() {
|
&bluesky,
|
||||||
record_embed = generate_embed_records(&bluesky, card).await;
|
quote_id.as_deref(),
|
||||||
}
|
&toot.media_attachments,
|
||||||
}
|
&toot.card,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| panic!("Cannot embed record for {}: {}", &toot.id, e));
|
||||||
|
|
||||||
// posts corresponding tweet
|
// posts corresponding tweet
|
||||||
let record = build_post_record(
|
let record = build_post_record(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::config::MastodonConfig;
|
use crate::config::MastodonConfig;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use megalodon::{
|
use megalodon::{
|
||||||
entities::{Status, StatusVisibility},
|
entities::{QuotedStatus, Status, StatusVisibility},
|
||||||
generator,
|
generator,
|
||||||
mastodon::mastodon::Mastodon,
|
mastodon::mastodon::Mastodon,
|
||||||
megalodon::AppInputOptions,
|
megalodon::AppInputOptions,
|
||||||
@@ -55,9 +55,19 @@ pub async fn get_mastodon_timeline_since(
|
|||||||
.clone()
|
.clone()
|
||||||
.is_some_and(|r| r == t.account.id)
|
.is_some_and(|r| r == t.account.id)
|
||||||
})
|
})
|
||||||
.filter(|t| t.visibility == StatusVisibility::Public) // excludes everything that isn’t
|
.filter(|t| t.visibility == StatusVisibility::Public) // excludes everything that isn’t public
|
||||||
// public
|
.filter(|t| t.reblog.is_none()) // exclude reblogs
|
||||||
.filter(|t| t.reblog.is_none()) // excludes reblogs
|
.filter(|t| {
|
||||||
|
// exclude quotes that aren’t ours
|
||||||
|
t.quote.is_none()
|
||||||
|
|| t.quote.clone().is_some_and(|r| match r {
|
||||||
|
QuotedStatus::Quote(q) => q
|
||||||
|
.quoted_status
|
||||||
|
.clone()
|
||||||
|
.is_some_and(|iq| iq.account.id == t.account.id),
|
||||||
|
_ => false,
|
||||||
|
})
|
||||||
|
})
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|||||||
146
src/utils.rs
146
src/utils.rs
@@ -1,7 +1,8 @@
|
|||||||
|
use atrium_api::{app::bsky::embed::defs::AspectRatioData, types::Object};
|
||||||
use html_escape::decode_html_entities;
|
use html_escape::decode_html_entities;
|
||||||
use megalodon::entities::status::Tag;
|
use megalodon::entities::{attachment::MetaSub, status::Tag};
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use std::error::Error;
|
use std::{error::Error, num::NonZeroU64};
|
||||||
|
|
||||||
/// Generate 2 contents out of 1 if that content is > 300 chars, None else
|
/// Generate 2 contents out of 1 if that content is > 300 chars, None else
|
||||||
pub fn generate_multi_tweets(content: &str) -> Option<(String, String)> {
|
pub fn generate_multi_tweets(content: &str) -> Option<(String, String)> {
|
||||||
@@ -53,10 +54,16 @@ fn twitter_count(content: &str) -> usize {
|
|||||||
count
|
count
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn strip_everything(content: &str, tags: &Vec<Tag>) -> Result<String, Box<dyn Error>> {
|
pub fn strip_everything(
|
||||||
|
content: &str,
|
||||||
|
tags: &Vec<Tag>,
|
||||||
|
mastodon_base: &str,
|
||||||
|
) -> Result<String, Box<dyn Error>> {
|
||||||
let mut res = strip_html_tags(&content.replace("</p><p>", "\n\n").replace("<br />", "\n"));
|
let mut res = strip_html_tags(&content.replace("</p><p>", "\n\n").replace("<br />", "\n"));
|
||||||
|
|
||||||
strip_mastodon_tags(&mut res, tags).unwrap();
|
strip_quote_header(&mut res, mastodon_base)?;
|
||||||
|
|
||||||
|
strip_mastodon_tags(&mut res, tags)?;
|
||||||
|
|
||||||
res = res.trim_end_matches('\n').trim_end_matches(' ').to_string();
|
res = res.trim_end_matches('\n').trim_end_matches(' ').to_string();
|
||||||
res = decode_html_entities(&res).to_string();
|
res = decode_html_entities(&res).to_string();
|
||||||
@@ -64,6 +71,16 @@ pub fn strip_everything(content: &str, tags: &Vec<Tag>) -> Result<String, Box<dy
|
|||||||
Ok(res)
|
Ok(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn strip_quote_header(content: &mut String, mastodon_base: &str) -> Result<(), Box<dyn Error>> {
|
||||||
|
let re = Regex::new(&format!(
|
||||||
|
r"^RE: {}\S+\n\n",
|
||||||
|
mastodon_base.replace(".", r"\.")
|
||||||
|
))?;
|
||||||
|
*content = re.replace(content, "").to_string();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn strip_mastodon_tags(content: &mut String, tags: &Vec<Tag>) -> Result<(), Box<dyn Error>> {
|
fn strip_mastodon_tags(content: &mut String, tags: &Vec<Tag>) -> Result<(), Box<dyn Error>> {
|
||||||
for tag in tags {
|
for tag in tags {
|
||||||
let re = Regex::new(&format!("(?i)(#{} ?)", &tag.name))?;
|
let re = Regex::new(&format!("(?i)(#{} ?)", &tag.name))?;
|
||||||
@@ -94,10 +111,119 @@ fn strip_html_tags(input: &str) -> String {
|
|||||||
data
|
data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn convert_aspect_ratio(m: &Option<MetaSub>) -> Option<Object<AspectRatioData>> {
|
||||||
|
match m {
|
||||||
|
Some(ms) => {
|
||||||
|
if ms.height.is_some_and(|x| x > 0) && ms.width.is_some_and(|x| x > 0) {
|
||||||
|
Some(
|
||||||
|
AspectRatioData {
|
||||||
|
// unwrap is safe here
|
||||||
|
height: NonZeroU64::new(ms.height.unwrap().into()).unwrap(),
|
||||||
|
width: NonZeroU64::new(ms.width.unwrap().into()).unwrap(),
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_convert_aspect_ratio() {
|
||||||
|
// test None orig aspect ratio
|
||||||
|
let metasub: Option<MetaSub> = None;
|
||||||
|
|
||||||
|
let result = convert_aspect_ratio(&metasub);
|
||||||
|
|
||||||
|
assert_eq!(result, None);
|
||||||
|
|
||||||
|
// test complet with image
|
||||||
|
let metasub = Some(MetaSub {
|
||||||
|
width: Some(1920),
|
||||||
|
height: Some(1080),
|
||||||
|
size: Some(String::from("1920x1080")),
|
||||||
|
aspect: Some(1.7777777777777777),
|
||||||
|
frame_rate: None,
|
||||||
|
duration: None,
|
||||||
|
bitrate: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
let expected_result = Some(
|
||||||
|
AspectRatioData {
|
||||||
|
height: NonZeroU64::new(1080).unwrap(),
|
||||||
|
width: NonZeroU64::new(1920).unwrap(),
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = convert_aspect_ratio(&metasub);
|
||||||
|
|
||||||
|
assert_eq!(result, expected_result);
|
||||||
|
|
||||||
|
// test complete with video
|
||||||
|
let metasub = Some(MetaSub {
|
||||||
|
width: Some(500),
|
||||||
|
height: Some(278),
|
||||||
|
size: None,
|
||||||
|
aspect: None,
|
||||||
|
frame_rate: Some(String::from("10/1")),
|
||||||
|
duration: Some(0.9),
|
||||||
|
bitrate: Some(973191),
|
||||||
|
});
|
||||||
|
|
||||||
|
let expected_result = Some(
|
||||||
|
AspectRatioData {
|
||||||
|
height: NonZeroU64::new(278).unwrap(),
|
||||||
|
width: NonZeroU64::new(500).unwrap(),
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = convert_aspect_ratio(&metasub);
|
||||||
|
|
||||||
|
assert_eq!(result, expected_result);
|
||||||
|
|
||||||
|
/* test broken shit
|
||||||
|
* that should never happened but you never know
|
||||||
|
*/
|
||||||
|
// zero width
|
||||||
|
let metasub = Some(MetaSub {
|
||||||
|
width: Some(0),
|
||||||
|
height: Some(278),
|
||||||
|
size: None,
|
||||||
|
aspect: None,
|
||||||
|
frame_rate: Some(String::from("10/1")),
|
||||||
|
duration: Some(0.9),
|
||||||
|
bitrate: Some(973191),
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = convert_aspect_ratio(&metasub);
|
||||||
|
|
||||||
|
assert_eq!(result, None);
|
||||||
|
|
||||||
|
// None height
|
||||||
|
let metasub = Some(MetaSub {
|
||||||
|
width: Some(500),
|
||||||
|
height: None,
|
||||||
|
size: None,
|
||||||
|
aspect: None,
|
||||||
|
frame_rate: Some(String::from("10/1")),
|
||||||
|
duration: Some(0.9),
|
||||||
|
bitrate: Some(973191),
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = convert_aspect_ratio(&metasub);
|
||||||
|
|
||||||
|
assert_eq!(result, None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_twitter_count() {
|
fn test_twitter_count() {
|
||||||
let content = "tamerelol?! 🐵";
|
let content = "tamerelol?! 🐵";
|
||||||
@@ -186,9 +312,19 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_strip_everything() {
|
fn test_strip_everything() {
|
||||||
|
// a classic toot
|
||||||
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 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 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();
|
let result = strip_everything(content, &vec![], "https://m.nintendojo.fr").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result, expected_result);
|
||||||
|
|
||||||
|
// a quoted toot
|
||||||
|
let content = "<p class=\"quote-inline\">RE: <a href=\"https://m.nintendojo.fr/@nintendojofr/115446347351491651\" target=\"_blank\" rel=\"nofollow noopener\" translate=\"no\"><span class=\"invisible\">https://</span><span class=\"ellipsis\">m.nintendojo.fr/@nintendojofr/</span><span class=\"invisible\">115446347351491651</span></a></p><p>Assassin’s Creed Shadows pèsera environ 62,8 Go sur Switch 2 (et un peu plus de 100 Go sur les autres supports), soit tout juste pour rentrer sur une cartouche de 64 Go.</p><p>Ou pas, pour rappel…</p><p><a href=\"https://m.nintendojo.fr/tags/AssassinsCreedShadows\" class=\"mention hashtag\" rel=\"tag\">#<span>AssassinsCreedShadows</span></a> <a href=\"https://m.nintendojo.fr/tags/Ubisoft\" class=\"mention hashtag\" rel=\"tag\">#<span>Ubisoft</span></a> <a href=\"https://m.nintendojo.fr/tags/NintendoSwitch2\" class=\"mention hashtag\" rel=\"tag\">#<span>NintendoSwitch2</span></a></p>";
|
||||||
|
|
||||||
|
let expected_result = "Assassin’s Creed Shadows pèsera environ 62,8 Go sur Switch 2 (et un peu plus de 100 Go sur les autres supports), soit tout juste pour rentrer sur une cartouche de 64 Go.\n\nOu pas, pour rappel…\n\n#AssassinsCreedShadows #Ubisoft #NintendoSwitch2";
|
||||||
|
|
||||||
|
let result = strip_everything(content, &vec![], "https://m.nintendojo.fr").unwrap();
|
||||||
|
|
||||||
assert_eq!(result, expected_result);
|
assert_eq!(result, expected_result);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user