mirror of
https://framagit.org/veretcle/oolatoocs.git
synced 2025-07-20 12:31:18 +02:00
Compare commits
17 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
402fcffc75 | ||
![]() |
b295cc5b94 | ||
![]() |
a882aaa59d | ||
![]() |
259032a7b9 | ||
![]() |
e7f0c9c6f5 | ||
![]() |
83c8da46e8 | ||
![]() |
823f80729f | ||
![]() |
5969e3a56a | ||
![]() |
3ea2478512 | ||
![]() |
5606d00da2 | ||
![]() |
4cb80b0607 | ||
![]() |
bbe14f1f30 | ||
![]() |
6fbc011914 | ||
![]() |
8f23c2459b | ||
![]() |
26805feadb | ||
![]() |
3a8fd538fc | ||
![]() |
891f46ec2f |
1735
Cargo.lock
generated
1735
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
13
Cargo.toml
13
Cargo.toml
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "oolatoocs"
|
||||
version = "4.1.1"
|
||||
version = "4.3.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
@@ -8,19 +8,20 @@ edition = "2021"
|
||||
[dependencies]
|
||||
chrono = "^0.4"
|
||||
clap = "^4"
|
||||
env_logger = "^0.10"
|
||||
env_logger = "^0.11"
|
||||
futures = "^0.3"
|
||||
html-escape = "^0.2"
|
||||
log = "^0.4"
|
||||
megalodon = "^0.13"
|
||||
megalodon = "^1.0"
|
||||
oauth1-request = "^0.6"
|
||||
regex = "^1.10"
|
||||
reqwest = { version = "^0.11", features = ["json", "stream", "multipart"] }
|
||||
rusqlite = { version = "^0.30", features = ["chrono"] }
|
||||
reqwest = { version = "^0.12", features = ["json", "stream", "multipart"] }
|
||||
rusqlite = { version = "^0.33", features = ["chrono"] }
|
||||
serde = { version = "^1.0", features = ["derive"] }
|
||||
tokio = { version = "^1.33", features = ["rt-multi-thread", "macros"] }
|
||||
toml = "^0.8"
|
||||
bsky-sdk = "^0.1"
|
||||
atrium-api = "^0.24"
|
||||
atrium-api = { version = "^0.25", features = ["namespace-appbsky"] }
|
||||
image = "^0.25"
|
||||
webp = "^0.3"
|
||||
|
||||
|
@@ -18,7 +18,7 @@ What it can do:
|
||||
* 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
|
||||
* ⚠️ 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.
|
||||
* Can reproduce threads from Mastodon to Bluesky
|
||||
* ⚠️ 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
|
||||
@@ -30,6 +30,7 @@ The configuration is relatively easy to follow:
|
||||
```toml
|
||||
[oolatoocs]
|
||||
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
|
||||
base = "https://m.nintendojo.fr"
|
||||
|
149
src/bsky.rs
149
src/bsky.rs
@@ -1,16 +1,20 @@
|
||||
use crate::config::BlueskyConfig;
|
||||
use atrium_api::{
|
||||
app::bsky::feed::post::RecordData, com::atproto::repo::upload_blob::Output,
|
||||
types::string::Datetime, types::string::Language,
|
||||
types::string::Datetime, types::string::Language, types::string::RecordKey,
|
||||
};
|
||||
use bsky_sdk::{
|
||||
agent::config::{Config, FileStore},
|
||||
rich_text::RichText,
|
||||
BskyAgent,
|
||||
};
|
||||
use futures::{stream, StreamExt};
|
||||
use image::ImageReader;
|
||||
use log::{debug, error};
|
||||
use megalodon::entities::attachment::{Attachment, AttachmentType};
|
||||
use log::{debug, error, warn};
|
||||
use megalodon::entities::{
|
||||
attachment::{Attachment, AttachmentType},
|
||||
card::Card,
|
||||
};
|
||||
use regex::Regex;
|
||||
use std::{error::Error, fs::exists, io::Cursor};
|
||||
use webp::*;
|
||||
@@ -135,7 +139,7 @@ async fn get_record(
|
||||
cid: None,
|
||||
collection: atrium_api::types::string::Nsid::new("app.bsky.feed.post".to_string())?,
|
||||
repo: atrium_api::types::string::Handle::new(config.to_string())?.into(),
|
||||
rkey: rkey.to_string(),
|
||||
rkey: RecordKey::new(rkey.to_string())?,
|
||||
}
|
||||
.into(),
|
||||
)
|
||||
@@ -144,7 +148,43 @@ async fn get_record(
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
// it’s ugly af but it gets the job done for now
|
||||
/// Generate an embed card record into Bsky
|
||||
/// If the preview image does not exist or fails to upload, it is simply ignored
|
||||
pub async fn generate_embed_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
|
||||
};
|
||||
|
||||
let record_card = atrium_api::app::bsky::embed::external::ExternalData {
|
||||
description: card.description.clone(),
|
||||
thumb: blob,
|
||||
title: card.title.clone(),
|
||||
uri: card.url.clone(),
|
||||
};
|
||||
|
||||
Some(atrium_api::types::Union::Refs(
|
||||
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(
|
||||
bsky: &BskyAgent,
|
||||
media_attach: &[Attachment],
|
||||
@@ -152,39 +192,66 @@ pub async fn generate_media_records(
|
||||
let mut embed: Option<
|
||||
atrium_api::types::Union<atrium_api::app::bsky::feed::post::RecordEmbedRefs>,
|
||||
> = None;
|
||||
let mut images = Vec::new();
|
||||
let mut videos: Vec<atrium_api::app::bsky::embed::video::MainData> = Vec::new();
|
||||
|
||||
for media in media_attach.iter() {
|
||||
match media.r#type {
|
||||
AttachmentType::Image => {
|
||||
let blob = upload_media(true, bsky, &media.url).await.unwrap();
|
||||
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();
|
||||
|
||||
images.push(
|
||||
// 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| {
|
||||
let bsky = bsky.clone();
|
||||
tokio::task::spawn(async move {
|
||||
debug!("Treating media {}", &media.url);
|
||||
upload_media(true, &bsky, &media.url).await.map(|i| {
|
||||
atrium_api::app::bsky::embed::images::ImageData {
|
||||
alt: media
|
||||
.description
|
||||
.clone()
|
||||
.map_or("".to_string(), |v| v.to_owned()),
|
||||
aspect_ratio: None,
|
||||
image: blob.data.blob,
|
||||
image: i.data.blob,
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
AttachmentType::Gifv | AttachmentType::Video => {
|
||||
let blob = upload_media(false, bsky, &media.url).await.unwrap();
|
||||
})
|
||||
})
|
||||
})
|
||||
.buffered(4);
|
||||
|
||||
videos.push(atrium_api::app::bsky::embed::video::MainData {
|
||||
alt: media.description.clone(),
|
||||
aspect_ratio: None,
|
||||
captions: None,
|
||||
video: blob.data.blob,
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
error!("Not an image, not a video, what happened here?");
|
||||
}
|
||||
let mut images = Vec::new();
|
||||
|
||||
while let Some(result) = stream.next().await {
|
||||
match result {
|
||||
Ok(Ok(v)) => images.push(v.into()),
|
||||
Ok(Err(e)) => warn!("Cannot treat a specific media: {}", e),
|
||||
Err(e) => error!("Something went wrong when joining main thread: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,19 +263,14 @@ pub async fn generate_media_records(
|
||||
));
|
||||
}
|
||||
|
||||
// if a video has been uploaded, it takes priority as you can only have 1 video per post
|
||||
if !videos.is_empty() {
|
||||
embed = Some(atrium_api::types::Union::Refs(
|
||||
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedVideoMain(Box::new(
|
||||
videos[0].clone().into(),
|
||||
)),
|
||||
))
|
||||
}
|
||||
|
||||
embed
|
||||
}
|
||||
|
||||
async fn upload_media(is_image: bool, bsky: &BskyAgent, u: &str) -> Result<Output, Box<dyn Error>> {
|
||||
async fn upload_media(
|
||||
is_image: bool,
|
||||
bsky: &BskyAgent,
|
||||
u: &str,
|
||||
) -> Result<Output, Box<dyn Error + Send + Sync>> {
|
||||
let dl = reqwest::get(u).await?;
|
||||
let content_length = dl.content_length().ok_or("Content length unavailable")?;
|
||||
let bytes = if content_length <= 1_000_000 || !is_image {
|
||||
@@ -216,11 +278,20 @@ async fn upload_media(is_image: bool, bsky: &BskyAgent, u: &str) -> Result<Outpu
|
||||
} else {
|
||||
// this is an image and it’s over 1Mb long
|
||||
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?))
|
||||
.with_guessed_format()?
|
||||
.decode()?;
|
||||
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()
|
||||
};
|
||||
|
||||
|
@@ -11,6 +11,17 @@ pub struct Config {
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct OolatoocsConfig {
|
||||
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)]
|
||||
|
25
src/lib.rs
25
src/lib.rs
@@ -18,7 +18,9 @@ mod utils;
|
||||
use utils::{generate_multi_tweets, strip_everything};
|
||||
|
||||
mod bsky;
|
||||
use bsky::{build_post_record, generate_media_records, get_session, BskyReply};
|
||||
use bsky::{
|
||||
build_post_record, generate_embed_records, generate_media_records, get_session, BskyReply,
|
||||
};
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
@@ -27,7 +29,8 @@ pub async fn run(config: &Config) {
|
||||
let conn = Connection::open(&config.oolatoocs.db_path)
|
||||
.unwrap_or_else(|e| panic!("Cannot open DB: {}", e));
|
||||
|
||||
let mastodon = get_mastodon_instance(&config.mastodon);
|
||||
let mastodon = get_mastodon_instance(&config.mastodon)
|
||||
.unwrap_or_else(|e| panic!("Cannot instantiate Mastodon: {}", e));
|
||||
|
||||
let bluesky = get_session(&config.bluesky)
|
||||
.await
|
||||
@@ -85,7 +88,12 @@ pub async fn run(config: &Config) {
|
||||
}
|
||||
|
||||
// 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) else {
|
||||
continue; // skip in case we can’t strip something
|
||||
};
|
||||
|
||||
@@ -151,14 +159,21 @@ pub async fn run(config: &Config) {
|
||||
};
|
||||
|
||||
// treats medias
|
||||
let record_medias = generate_media_records(&bluesky, &toot.media_attachments).await;
|
||||
let mut record_embed = generate_media_records(&bluesky, &toot.media_attachments).await;
|
||||
|
||||
// treats embed cards if any
|
||||
if let Some(card) = &toot.card {
|
||||
if record_embed.is_none() {
|
||||
record_embed = generate_embed_records(&bluesky, card).await;
|
||||
}
|
||||
}
|
||||
|
||||
// posts corresponding tweet
|
||||
let record = build_post_record(
|
||||
&config.bluesky,
|
||||
&tweet_content,
|
||||
&toot.language,
|
||||
record_medias,
|
||||
record_embed,
|
||||
&record_reply_to,
|
||||
)
|
||||
.await
|
||||
|
@@ -12,12 +12,12 @@ use std::error::Error;
|
||||
use std::io::stdin;
|
||||
|
||||
/// Get Mastodon Object instance
|
||||
pub fn get_mastodon_instance(config: &MastodonConfig) -> Mastodon {
|
||||
Mastodon::new(
|
||||
pub fn get_mastodon_instance(config: &MastodonConfig) -> Result<Mastodon, Box<dyn Error>> {
|
||||
Ok(Mastodon::new(
|
||||
config.base.to_string(),
|
||||
Some(config.token.to_string()),
|
||||
None,
|
||||
)
|
||||
)?)
|
||||
}
|
||||
|
||||
/// Get the edited_at field from the specified toot
|
||||
@@ -71,7 +71,8 @@ pub async fn get_mastodon_timeline_since(
|
||||
/// Most of this function is a direct copy/paste of the official `elefren` crate
|
||||
#[tokio::main]
|
||||
pub async fn register(host: &str) {
|
||||
let mastodon = generator(megalodon::SNS::Mastodon, host.to_string(), None, None);
|
||||
let mastodon = generator(megalodon::SNS::Mastodon, host.to_string(), None, None)
|
||||
.expect("Cannot build Mastodon generator object");
|
||||
|
||||
let options = AppInputOptions {
|
||||
redirect_uris: None,
|
||||
|
@@ -82,10 +82,7 @@ pub fn write_state(conn: &Connection, t: TootRecord) -> Result<(), Box<dyn Error
|
||||
|
||||
/// Initiates the DB from path
|
||||
pub fn init_db(d: &str) -> Result<(), Box<dyn Error>> {
|
||||
debug!(
|
||||
"{}",
|
||||
format!("Initializing DB for {}", env!("CARGO_PKG_NAME"))
|
||||
);
|
||||
debug!("Initializing DB for {}", env!("CARGO_PKG_NAME"));
|
||||
let conn = Connection::open(d)?;
|
||||
|
||||
conn.execute(
|
||||
|
14
src/utils.rs
14
src/utils.rs
@@ -38,7 +38,13 @@ fn twitter_count(content: &str) -> usize {
|
||||
|
||||
for word in split_content {
|
||||
if word.starts_with("http://") || word.starts_with("https://") {
|
||||
count += 23;
|
||||
// It’s not that simple. Bsky adapts itself to the URL.
|
||||
// https://github.com -> 10 chars
|
||||
// https://github.com/ -> 10 chars
|
||||
// https://github.com/NVNTLabs -> 19 chars
|
||||
// https://github.com/NVNTLabs/ -> 20 chars
|
||||
// so taking the maximum here to simplify things
|
||||
count += 26;
|
||||
} else {
|
||||
count += word.chars().count();
|
||||
}
|
||||
@@ -100,11 +106,11 @@ mod tests {
|
||||
|
||||
let content = "Shoot out to https://y.ml/ !";
|
||||
|
||||
assert_eq!(twitter_count(content), 38);
|
||||
assert_eq!(twitter_count(content), 41);
|
||||
|
||||
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);
|
||||
assert_eq!(twitter_count(content), 79);
|
||||
|
||||
let content = "multi ple space";
|
||||
|
||||
@@ -112,7 +118,7 @@ mod tests {
|
||||
|
||||
let content = "This link is LEEEEET\n\nhttps://www.factornews.com/actualites/ca-sent-le-sapin-pour-free-radical-design-49985.html";
|
||||
|
||||
assert_eq!(twitter_count(content), 45);
|
||||
assert_eq!(twitter_count(content), 48);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
Reference in New Issue
Block a user