11 Commits

Author SHA1 Message Date
VC
a882aaa59d Merge branch '15-feat-make-hashtags-removal-optional' into 'main'
: hashtag removal is now optional

Closes #15

See merge request veretcle/oolatoocs!34
2025-06-21 10:53:43 +00:00
VC
259032a7b9 : hashtag removal is now optional 2025-06-21 12:48:59 +02:00
VC
e7f0c9c6f5 Merge branch '14-image-size-find-a-way-to-optimize-image-more' into 'main'
🐛: add progressively more compression to WebP to avoid getting rejected with...

Closes #14

See merge request veretcle/oolatoocs!33
2025-06-20 12:33:21 +00:00
VC
83c8da46e8 🐛: add progressively more compression to WebP to avoid getting rejected with 1Mb limit image file size 2025-06-20 14:26:12 +02:00
VC
823f80729f Merge branch '13-update-bsky-sdk-dependency' into 'main'
⬆: bsky-sdk v0.1.20 + atrium_api v0.25.4

Closes #13

See merge request veretcle/oolatoocs!32
2025-06-16 06:28:38 +00:00
VC
5969e3a56a ⬆: bsky-sdk v0.1.20 + atrium_api v0.25.4 2025-06-12 15:16:35 +02:00
VC
3ea2478512 fix: count 26 chars per url each time 2025-06-12 14:37:02 +02:00
VC
5606d00da2 Merge branch '10-better-embed-links-for-bsky' into 'main'
: add embed card when available

Closes #10

See merge request veretcle/oolatoocs!29
2025-01-26 08:50:43 +00:00
VC
4cb80b0607 : add embed card when available 2025-01-26 09:33:20 +01:00
VC
bbe14f1f30 Merge branch 'feat_update_dependencies' into 'main'
⬆️: update all dependencies

See merge request veretcle/oolatoocs!28
2025-01-24 14:43:51 +00:00
VC
6fbc011914 ⬆️: update all dependencies 2025-01-24 15:38:46 +01:00
7 changed files with 1224 additions and 562 deletions

1666
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "oolatoocs" name = "oolatoocs"
version = "4.1.3" version = "4.3.0"
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
@@ -8,20 +8,20 @@ edition = "2021"
[dependencies] [dependencies]
chrono = "^0.4" chrono = "^0.4"
clap = "^4" clap = "^4"
env_logger = "^0.10" 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" megalodon = "^1.0"
oauth1-request = "^0.6" oauth1-request = "^0.6"
regex = "^1.10" regex = "^1.10"
reqwest = { version = "^0.11", features = ["json", "stream", "multipart"] } reqwest = { version = "^0.12", features = ["json", "stream", "multipart"] }
rusqlite = { version = "^0.30", features = ["chrono"] } rusqlite = { version = "^0.33", features = ["chrono"] }
serde = { version = "^1.0", features = ["derive"] } serde = { version = "^1.0", features = ["derive"] }
tokio = { version = "^1.33", features = ["rt-multi-thread", "macros"] } tokio = { version = "^1.33", features = ["rt-multi-thread", "macros"] }
toml = "^0.8" toml = "^0.8"
bsky-sdk = "^0.1" bsky-sdk = "^0.1"
atrium-api = "^0.24" atrium-api = { version = "^0.25", features = ["namespace-appbsky"] }
image = "^0.25" image = "^0.25"
webp = "^0.3" webp = "^0.3"

View File

@@ -1,7 +1,7 @@
use crate::config::BlueskyConfig; use crate::config::BlueskyConfig;
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::Datetime, types::string::Language, types::string::RecordKey,
}; };
use bsky_sdk::{ use bsky_sdk::{
agent::config::{Config, FileStore}, agent::config::{Config, FileStore},
@@ -11,7 +11,10 @@ use bsky_sdk::{
use futures::{stream, StreamExt}; use futures::{stream, StreamExt};
use image::ImageReader; use image::ImageReader;
use log::{debug, error, warn}; use log::{debug, error, warn};
use megalodon::entities::attachment::{Attachment, AttachmentType}; use megalodon::entities::{
attachment::{Attachment, AttachmentType},
card::Card,
};
use regex::Regex; use regex::Regex;
use std::{error::Error, fs::exists, io::Cursor}; use std::{error::Error, fs::exists, io::Cursor};
use webp::*; use webp::*;
@@ -136,7 +139,7 @@ async fn get_record(
cid: None, cid: None,
collection: atrium_api::types::string::Nsid::new("app.bsky.feed.post".to_string())?, collection: atrium_api::types::string::Nsid::new("app.bsky.feed.post".to_string())?,
repo: atrium_api::types::string::Handle::new(config.to_string())?.into(), repo: atrium_api::types::string::Handle::new(config.to_string())?.into(),
rkey: rkey.to_string(), rkey: RecordKey::new(rkey.to_string())?,
} }
.into(), .into(),
) )
@@ -145,7 +148,43 @@ async fn get_record(
Ok(record) Ok(record)
} }
// its 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( pub async fn generate_media_records(
bsky: &BskyAgent, bsky: &BskyAgent,
media_attach: &[Attachment], media_attach: &[Attachment],
@@ -239,11 +278,20 @@ async fn upload_media(
} else { } else {
// this is an image and its over 1Mb long // this is an image and its 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()
}; };

View File

@@ -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)]

View File

@@ -18,7 +18,9 @@ mod utils;
use utils::{generate_multi_tweets, strip_everything}; use utils::{generate_multi_tweets, strip_everything};
mod bsky; 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; use rusqlite::Connection;
@@ -86,7 +88,12 @@ 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) else {
continue; // skip in case we cant strip something continue; // skip in case we cant strip something
}; };
@@ -152,14 +159,21 @@ pub async fn run(config: &Config) {
}; };
// treats medias // 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 // posts corresponding tweet
let record = build_post_record( let record = build_post_record(
&config.bluesky, &config.bluesky,
&tweet_content, &tweet_content,
&toot.language, &toot.language,
record_medias, record_embed,
&record_reply_to, &record_reply_to,
) )
.await .await

View File

@@ -82,10 +82,7 @@ pub fn write_state(conn: &Connection, t: TootRecord) -> Result<(), Box<dyn Error
/// Initiates the DB from path /// Initiates the DB from path
pub fn init_db(d: &str) -> Result<(), Box<dyn Error>> { pub fn init_db(d: &str) -> Result<(), Box<dyn Error>> {
debug!( debug!("Initializing DB for {}", env!("CARGO_PKG_NAME"));
"{}",
format!("Initializing DB for {}", env!("CARGO_PKG_NAME"))
);
let conn = Connection::open(d)?; let conn = Connection::open(d)?;
conn.execute( conn.execute(

View File

@@ -38,7 +38,13 @@ fn twitter_count(content: &str) -> usize {
for word in split_content { for word in split_content {
if word.starts_with("http://") || word.starts_with("https://") { if word.starts_with("http://") || word.starts_with("https://") {
count += 23; // Its 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 { } else {
count += word.chars().count(); count += word.chars().count();
} }
@@ -100,11 +106,11 @@ mod tests {
let content = "Shoot out to https://y.ml/ !"; 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"; 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"; 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"; 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] #[test]