31 Commits

Author SHA1 Message Date
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
VC
7b21a0e3a7 Merge branch 'feat/add_quotes' into 'main'
Add quotes

See merge request veretcle/oolatoocs!37
2025-11-26 06:36:08 +00:00
VC
43aa6dcd99 : add mastodon quotes 2025-11-25 21:42:10 +01:00
VC
cf5fe11b56 Merge branch 'feat_rust_1_90' into 'main'
⬆️: rust 1.90

See merge request veretcle/oolatoocs!36
2025-09-26 13:41:15 +00:00
VC
7bd0843cf6 ⬆️: rust 1.90 2025-09-26 15:33:47 +02:00
VC
402fcffc75 Merge branch 'doc_update' into 'main'
📝: update README.md

See merge request veretcle/oolatoocs!35
2025-06-21 11:01:28 +00:00
VC
b295cc5b94 📝: update README.md 2025-06-21 12:56:09 +02:00
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
VC
8f23c2459b Merge branch 'feat_megalodon_update' into 'main'
⬆️: megalodon 1.0.0

See merge request veretcle/oolatoocs!27
2025-01-24 14:20:44 +00:00
VC
26805feadb ⬆️: megalodon 1.0.0 2025-01-24 15:12:08 +01:00
VC
3a8fd538fc Merge branch '11-optimize-image-upload' into 'main'
🎨: improve bsky image upload

Closes #11

See merge request veretcle/oolatoocs!26
2025-01-24 13:43:06 +00:00
VC
891f46ec2f 🎨: improve bsky image upload 2025-01-24 14:34:01 +01:00
12 changed files with 1762 additions and 1152 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 }}'

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@
.last_tweet
.config.toml
.config.json
.bsky.json

2350
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "oolatoocs"
version = "4.1.1"
version = "4.5.2"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -8,21 +8,22 @@ 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"
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"
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,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 (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
@@ -30,6 +33,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"

View File

@@ -1,16 +1,20 @@
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::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,71 +148,220 @@ async fn get_record(
Ok(record)
}
// its ugly af but it gets the job done for now
pub async fn generate_media_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,
bsky: &BskyAgent,
qid: Option<&str>,
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 mut images = Vec::new();
let mut videos: Vec<atrium_api::app::bsky::embed::video::MainData> = Vec::new();
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,
};
for media in media_attach.iter() {
match media.r#type {
AttachmentType::Image => {
let blob = upload_media(true, bsky, &media.url).await.unwrap();
// 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();
images.push(
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,
};
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(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 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 {
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 {
description: card.description.clone(),
thumb: blob,
title: card.title.clone(),
uri: card.url.clone(),
};
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 image media records
async fn generate_images_records(
bsky: &BskyAgent,
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 {
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,
aspect_ratio: convert_aspect_ratio(
&media.meta.as_ref().and_then(|m| m.original.clone()),
),
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),
}
}
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(),
)),
));
);
}
// 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
Err(OolatoocsError::new("Cannot embed media").into())
}
async fn upload_media(is_image: bool, bsky: &BskyAgent, u: &str) -> Result<Output, Box<dyn Error>> {
/// 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(
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 +369,20 @@ async fn upload_media(is_image: bool, bsky: &BskyAgent, u: &str) -> Result<Outpu
} else {
// this is an image and its 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()
};

View File

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

View File

@@ -18,7 +18,7 @@ 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, get_session, BskyReply};
use rusqlite::Connection;
@@ -27,7 +27,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 +86,14 @@ 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, &config.mastodon.base)
else {
continue; // skip in case we cant strip something
};
@@ -150,15 +158,31 @@ pub async fn run(config: &Config) {
});
};
// treats medias
let record_medias = generate_media_records(&bluesky, &toot.media_attachments).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,
},
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,
&tweet_content,
&toot.language,
record_medias,
record_embed,
&record_reply_to,
)
.await

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,
@@ -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
@@ -55,9 +55,19 @@ pub async fn get_mastodon_timeline_since(
.clone()
.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()) // excludes reblogs
.filter(|t| t.visibility == StatusVisibility::Public) // excludes everything that isnt public
.filter(|t| t.reblog.is_none()) // exclude reblogs
.filter(|t| {
// 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();
@@ -71,7 +81,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,

View File

@@ -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(

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)> {
@@ -38,7 +39,13 @@ fn twitter_count(content: &str) -> usize {
for word in split_content {
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 {
count += word.chars().count();
}
@@ -47,10 +54,16 @@ fn twitter_count(content: &str) -> usize {
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"));
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 = decode_html_entities(&res).to_string();
@@ -58,6 +71,16 @@ pub fn strip_everything(content: &str, tags: &Vec<Tag>) -> Result<String, Box<dy
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>> {
for tag in tags {
let re = Regex::new(&format!("(?i)(#{} ?)", &tag.name))?;
@@ -88,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?! 🐵";
@@ -100,11 +232,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 +244,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]
@@ -180,9 +312,19 @@ mod tests {
#[test]
fn test_strip_everything() {
// a classic toot
let content = "<p>Ce soir à 21h, c&#39;est le Dojobar ! Au programme ce soir, une rétrospective sur la série Mario &amp; Luigi.<br />Comme d&#39;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&#39;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();
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>Assassins 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 = "Assassins 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);
}