14 Commits
v4.4.0 ... main

Author SHA1 Message Date
VC
937cb38ec4 Merge branch 'fix/video+images' into 'main'
All checks were successful
rust-check / rust-check (push) Successful in 5m45s
🚑️: version boost

See merge request veretcle/oolatoocs!44
2025-12-03 20:35:50 +00:00
VC
8123ed429e 🚑️: version boost 2025-12-03 21:29:23 +01:00
VC
85d469f325 Merge branch 'fix/video+images' into 'main'
Some checks failed
rust-check / rust-check (push) Has been cancelled
🚑️: fix record embed not properly generated

See merge request veretcle/oolatoocs!43
2025-12-03 20:01:08 +00:00
VC
e1a980ec81 🚑️: fix record embed not properly generated 2025-12-03 20:56:11 +01:00
VC
9cda013272 Merge branch 'ci/add_gitea_workflows' into 'main'
ci: add gitea workflow

See merge request veretcle/oolatoocs!42
2025-12-03 10:01:31 +00:00
VC
aee880e4bb 👷: add gitea workflow 2025-12-03 10:54:47 +01:00
VC
498095d3a8 Merge branch '17-better-handling-of-embedded-records' into 'main'
♻️: better handling of embedded records

Closes #17

See merge request veretcle/oolatoocs!41
2025-12-02 14:03:04 +00:00
VC
5ee64014eb ♻️: better handling of embedded records 2025-12-02 13:37:53 +01:00
VC
639582ba59 Merge branch '16-add-aspectratio-to-image-handling' into 'main'
: copy aspect ratio for images & video

Closes #16

See merge request veretcle/oolatoocs!40
2025-12-01 15:48:36 +00:00
VC
43ca862d5a : copy aspect ratio for images & video 2025-12-01 16:39:37 +01:00
VC
47d7fdbd42 Merge branch 'feat/refactor_embeds' into 'main'
♻️: better handle of quotes and media embedded into quotes

See merge request veretcle/oolatoocs!39
2025-12-01 12:07:42 +00:00
VC
7334fb3d09 ♻️: better handle of quotes and media embedded into quotes 2025-12-01 11:37:58 +01:00
VC
79ac915347 Merge branch 'feat/megalodon_1_1' into 'main'
⬆️: upgrade megalodon v1.1

See merge request veretcle/oolatoocs!38
2025-11-27 08:13:17 +00:00
VC
e89e6e51ec ⬆️: upgrade megalodon v1.1 2025-11-27 09:08:27 +01:00
9 changed files with 433 additions and 523 deletions

View File

@@ -0,0 +1,20 @@
---
name: rust-check
on: # yamllint disable-line rule:truthy
- push
jobs:
rust-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
components: "clippy,rustfmt"
- run: cargo fmt -- --check
- run: cargo check
- run: cargo clippy -- -D warnings
- run: cargo test
- run: cargo build

View File

@@ -0,0 +1,23 @@
---
name: rust-release
on: # yamllint disable-line rule:truthy
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- name: Get package name
run: echo "CARGO_PKG_NAME=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].name')" >> $GITEA_ENV
- run: cargo build --release
- uses: https://gitea.com/actions/gitea-release-action@v1
with:
files: |-
target/release/${{ env.CARGO_PKG_NAME }}
api_key: '${{ secrets.RELEASE_TOKEN }}'

522
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "oolatoocs"
version = "4.4.0"
version = "4.5.3"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -23,7 +23,7 @@ bsky-sdk = "^0.1"
atrium-api = { version = "^0.25", features = ["namespace-appbsky"] }
image = "^0.25"
webp = "^0.3"
megalodon = "1.0.*"
megalodon = "^1.1"
[profile.release]
strip = true

View File

@@ -16,10 +16,13 @@ Since 2025-01-20, Twitter is now longer supported.
What it can do:
* Reproduces the Toot content into the Record;
* Cuts (poorly) the Toot in half in its 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 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 (self-)quotes from Mastodon to Bluesky
* ⚠️ Bluesky cant 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.
* Can prevent a Toot from being recorded to Bluesky by using the #NoTweet (case-insensitive) hashtag in Mastodon

View File

@@ -1,4 +1,4 @@
use crate::config::BlueskyConfig;
use crate::{config::BlueskyConfig, utils::convert_aspect_ratio, OolatoocsError};
use atrium_api::{
app::bsky::feed::post::RecordData, com::atproto::repo::upload_blob::Output,
types::string::Datetime, types::string::Language, types::string::RecordKey,
@@ -148,18 +148,103 @@ async fn get_record(
Ok(record)
}
/// Generate an quote embed record into Bsky
pub async fn generate_quote_records(
/// Generate a Union of embed records to be built-in into records
/// In case an embed cannot be uploaded/created, this calling function silently gets Option instead
/// of failing
pub async fn generate_embed_records(
config: &BlueskyConfig,
quote_id: &str,
) -> Option<atrium_api::types::Union<atrium_api::app::bsky::feed::post::RecordEmbedRefs>> {
// if we cant match the quote_id, simply return None
let quote_record = match get_record(&config.handle, &rkey(quote_id)).await {
Ok(a) => a,
Err(_) => return None,
bsky: &BskyAgent,
qid: Option<&str>,
media_attach: &[Attachment],
card: &Option<Card>,
) -> Result<
Option<atrium_api::types::Union<atrium_api::app::bsky::feed::post::RecordEmbedRefs>>,
Box<dyn Error + Send + Sync>,
> {
// handle quote if any
let quote_embed = match qid {
Some(q) => generate_quote_records(config, q).await.ok(),
_ => None,
};
Some(atrium_api::types::Union::Refs(
// 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 {
return Err(OolatoocsError::new("A media attached is not an image nor a video").into());
}
} else {
None
};
// handle webcard if any
let webcard_embed = match card {
Some(t) => generate_webcard_records(bsky, t).await.ok(),
None => None,
};
let record_embed = 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: cant 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: cant 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(record_embed)
}
/// 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 cant 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 {
@@ -170,24 +255,18 @@ pub async fn generate_quote_records(
}
.into(),
)),
))
)
}
/// Generate an embed card record into Bsky
/// Generate an embed webcard record into Bsky
/// If the preview image does not exist or fails to upload, it is simply ignored
pub async fn generate_embed_records(
async fn generate_webcard_records(
bsky: &BskyAgent,
card: &Card,
) -> Option<atrium_api::types::Union<atrium_api::app::bsky::feed::post::RecordEmbedRefs>> {
// uploads the image card, if it fails, simply ignore everything
let blob = if let Some(url) = &card.image {
if let Ok(image_blob) = upload_media(true, bsky, url).await {
Some(image_blob.blob.clone())
} else {
None
}
} else {
None
) -> 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 {
@@ -197,61 +276,22 @@ pub async fn generate_embed_records(
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::embed::external::MainData {
external: record_card.into(),
}
.into(),
)),
))
)
}
/// Generate an array of Bsky media records
/// As Bsky does not support multiple video in a record or mix of video and images, video has the
/// highest priority
pub async fn generate_media_records(
/// Generate an array of Bsky image media records
async fn generate_images_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 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 well 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 dont want to treat the other medias
return embed;
}
let mut stream = stream::iter(image_media_attach)
media_attach: Vec<Attachment>,
) -> Result<atrium_api::app::bsky::feed::post::RecordEmbedRefs, Box<dyn Error + Send + Sync>> {
let mut stream = stream::iter(media_attach)
.map(|media| {
let bsky = bsky.clone();
tokio::task::spawn(async move {
@@ -262,7 +302,9 @@ pub async fn generate_media_records(
.description
.clone()
.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,
}
})
@@ -281,14 +323,38 @@ pub async fn generate_media_records(
}
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::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(

View File

@@ -18,10 +18,7 @@ mod utils;
use utils::{generate_multi_tweets, strip_everything};
mod bsky;
use bsky::{
build_post_record, generate_embed_records, generate_media_records, generate_quote_records,
get_session, BskyReply,
};
use bsky::{build_post_record, generate_embed_records, get_session, BskyReply};
use rusqlite::Connection;
@@ -161,26 +158,25 @@ pub async fn run(config: &Config) {
});
};
// Dont know how to union things so…
// cards have the least priority (you cannot embed card and images anyway)
// images have a higher priority
// quotes have the highest priority
let record_embed = if toot.reblog.is_some() {
let quote_record =
read_state(&conn, Some(toot.reblog.unwrap().id.parse::<u64>().unwrap()));
match quote_record {
Ok(Some(q)) => generate_quote_records(&config.bluesky, &q.record_uri).await,
// get quote_id if any
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,
}
} else if toot.media_attachments.len() > usize::from(0u8) {
generate_media_records(&bluesky, &toot.media_attachments).await
} else if toot.card.is_some() {
generate_embed_records(&bluesky, &toot.card.unwrap()).await
} else {
None
},
None => None,
};
let record_embed = generate_embed_records(
&config.bluesky,
&bluesky,
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
let record = build_post_record(
&config.bluesky,

View File

@@ -1,7 +1,7 @@
use crate::config::MastodonConfig;
use chrono::{DateTime, Utc};
use megalodon::{
entities::{Status, StatusVisibility},
entities::{QuotedStatus, Status, StatusVisibility},
generator,
mastodon::mastodon::Mastodon,
megalodon::AppInputOptions,
@@ -56,12 +56,18 @@ pub async fn get_mastodon_timeline_since(
.is_some_and(|r| r == t.account.id)
})
.filter(|t| t.visibility == StatusVisibility::Public) // excludes everything that isnt public
.filter(|t| t.reblog.is_none()) // exclude reblogs
.filter(|t| {
t.reblog.is_none()
|| t.reblog
.clone()
.is_some_and(|r| r.account.id == t.account.id)
}) // excludes reblogs except by self
// exclude quotes that arent 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()
.collect();

View File

@@ -1,7 +1,8 @@
use atrium_api::{app::bsky::embed::defs::AspectRatioData, types::Object};
use html_escape::decode_html_entities;
use megalodon::entities::status::Tag;
use megalodon::entities::{attachment::MetaSub, status::Tag};
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
pub fn generate_multi_tweets(content: &str) -> Option<(String, String)> {
@@ -110,10 +111,119 @@ fn strip_html_tags(input: &str) -> String {
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)]
mod tests {
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]
fn test_twitter_count() {
let content = "tamerelol?! 🐵";