69 Commits

Author SHA1 Message Date
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
VC
cad7840f98 Merge branch '12-optimize-text-cutting-function-for-bsky' into 'main'
🦺: add 300 chars limit for cutting off toots

Closes #12

See merge request veretcle/oolatoocs!25
2025-01-23 10:30:32 +00:00
VC
6e7299585a 🦺: add 300 chars limit for cutting off toots 2025-01-23 11:25:05 +01:00
VC
62efcb8112 Merge branch '8-feat-resize-image-when-too-large' into 'main'
: automatically convert image to webp when over 1Mb

Closes #8

See merge request veretcle/oolatoocs!24
2025-01-18 08:45:43 +00:00
VC
49279e7f1f : automatically convert image to webp when over 1Mb 2025-01-17 20:54:58 +01:00
VC
f05669923f Merge branch 'feat_remove_twitter' into 'main'
💥: now incompatible with Twitter

See merge request veretcle/oolatoocs!23
2025-01-17 19:54:11 +00:00
VC
9da43beb34 💥: now incompatible with Twitter 2025-01-13 17:16:47 +01:00
VC
e99a666b18 Merge branch 'fix_rand_doc' into 'main'
Fix rand doc

See merge request veretcle/oolatoocs!22
2024-10-09 09:26:25 +00:00
VC
3b18dac2fb 📝: update doc 2024-10-09 11:22:06 +02:00
VC
af977a1ee0 : remove rand 2024-10-09 11:14:07 +02:00
VC
b90b727783 Merge branch 'feat_www' into 'main'
💄: remove www. from url

See merge request veretcle/oolatoocs!21
2024-10-08 08:33:03 +00:00
VC
f8227f99c1 💄: remove www. from url 2024-10-08 10:24:22 +02:00
VC
9f2ff119ff Merge branch 'feat_better_session_handling' into 'main'
♻️: refactor bsky session

See merge request veretcle/oolatoocs!20
2024-10-07 12:50:34 +00:00
VC
c0244c8c30 ♻️: refactor bsky session 2024-10-07 11:55:20 +02:00
VC
89aec3e0ed Merge branch 'fix_429' into 'main'
🚑️: avoid opening session when not necessary

See merge request veretcle/oolatoocs!19
2024-10-03 07:44:31 +00:00
VC
6a7eef757a 🚑️: avoid opening session when not necessary 2024-10-03 09:38:50 +02:00
VC
f46f90ad34 Merge branch 'fix_rt_links' into 'main'
🐛: … was apposed to every link regardless of their length

See merge request veretcle/oolatoocs!18
2024-10-02 08:50:37 +00:00
VC
4b4f9abe2f 🐛: … was apposed to every link regardless of their length 2024-10-02 10:44:43 +02:00
VC
9f9cf52722 Merge branch '7-feat-add-bsky-support' into 'main'
feat: add bsky support

Closes #7

See merge request veretcle/oolatoocs!17
2024-10-02 06:55:42 +00:00
VC
e0d3667fb9 💄: better record link content 2024-10-02 08:51:11 +02:00
VC
ac8af5ce95 : add bluesky support 2024-10-01 12:37:31 +02:00
VC
f7e2aafa7b Merge branch 'rust_1.81.0' into 'main'
⬆️: update version

See merge request veretcle/oolatoocs!16
2024-09-09 08:52:57 +00:00
VC
6e9bb6b42c ⬆️: update version 2024-09-09 10:45:45 +02:00
VC
88edb1b2e1 Merge branch 'rust_1_76' into 'main'
📦: cargo update

See merge request veretcle/oolatoocs!15
2024-03-21 09:53:17 +00:00
VC
bf9d27df61 📦: cargo update 2024-03-21 10:48:58 +01:00
VC
496dde60d6 Merge branch 'fix_twitter_pool_length' into 'main'
feat: truncate poll when too long

See merge request veretcle/oolatoocs!14
2024-01-17 14:24:12 +00:00
VC
567dfae7ab feat: truncate poll when too long 2024-01-17 15:18:12 +01:00
VC
eeaea52e80 Merge branch 'refactor_delete' into 'main'
Refactor delete

See merge request veretcle/oolatoocs!13
2024-01-10 10:31:36 +00:00
VC
4a0dbb06af 📦: bump version 2024-01-10 11:24:56 +01:00
VC
5c17ea6989 ♻ : avoid url duplication 2024-01-10 11:23:09 +01:00
VC
8674048e8d Merge branch '6-feat-add-the-ability-to-rollback-last-tweet' into 'main'
feat: add the ability to rollback last tweet

Closes #6

See merge request veretcle/oolatoocs!12
2024-01-09 13:04:01 +00:00
VC
378d973697 feat: add the ability to rewrite an edited toot 2024-01-09 13:57:43 +01:00
VC
2cb732efed Merge branch 'refresh_main' into 'main'
chore: update megalodon-rs to 0.11.7

See merge request veretcle/oolatoocs!11
2023-12-22 08:09:55 +00:00
VC
5d685b5748 chore: update megalodon-rs to 0.11.7 2023-12-22 09:06:00 +01:00
VC
66664ff621 Merge branch 'feat_better_split' into 'main'
Feat better split

See merge request veretcle/oolatoocs!10
2023-11-29 12:36:00 +00:00
VC
fd84730bdc feat: better split for twitter_count 2023-11-29 13:32:04 +01:00
VC
692f4ff040 chore: bump version 2023-11-29 13:31:45 +01:00
VC
3397416a93 Merge branch 'fix_twitter_count' into 'main'
Fix twitter count

See merge request veretcle/oolatoocs!9
2023-11-29 10:38:27 +00:00
VC
f782987991 chore: bump dependencies’ version 2023-11-29 11:28:10 +01:00
VC
26788f9d37 fix: properly count URL when preceeded by '\n' 2023-11-29 11:25:27 +01:00
VC
ca9b388a50 chore: bump version 2023-11-29 11:24:38 +01:00
VC
42958e0a92 Merge branch 'fix_u16' into 'main'
fix: use u16 instead of i64

See merge request veretcle/oolatoocs!8
2023-11-22 07:57:03 +00:00
VC
77be17e7bf fix: use u16 instead of i64 2023-11-22 08:53:17 +01:00
VC
bd9fd27fd1 Merge branch '5-feat-repeat-mastodon-poll-in-twitter' into 'main'
feat: add poll from Mastodon to Twitter + pass owned values in post_tweet

Closes #5

See merge request veretcle/oolatoocs!7
2023-11-21 22:20:49 +00:00
VC
3e6cae6136 feat: add poll from Mastodon to Twitter + pass owned values in post_tweet 2023-11-21 23:13:40 +01:00
VC
f10baa3eb2 Merge branch '1-find-a-way-to-not-carry-a-toot' into 'main'
Find a way to not carry a toot

Closes #1

See merge request veretcle/oolatoocs!6
2023-11-21 13:03:01 +00:00
VC
c113c1472a doc: add README 2023-11-21 13:59:14 +01:00
VC
cdf7dc70c1 feat: add #NoTweet to skip toot from being tweeted 2023-11-21 13:27:37 +01:00
VC
b1aed34f3c Merge branch '3-cut-toot-in-half-when-they-re-too-big' into 'main'
Cut toot in half

Closes #3

See merge request veretcle/oolatoocs!5
2023-11-20 14:53:19 +00:00
VC
e8bde4c779 feat: move media generation list to twitter.rs to avoid clutter 2023-11-20 15:32:02 +01:00
VC
80946ac131 chore: cargo update 2023-11-20 15:32:02 +01:00
VC
87b0567b59 feat: split toot into 2 tweets when necessary 2023-11-20 15:32:02 +01:00
VC
b6f87e829f Merge branch '2-find-a-way-to-remove-dissolve' into 'main'
feat: remove dissolve + add simpler html tag stripper + html entities

Closes #2

See merge request veretcle/oolatoocs!4
2023-11-17 19:30:36 +00:00
VC
6fccbf8d16 feat: remove dissolve + add simpler html tag stripper + html entities 2023-11-17 20:08:07 +01:00
VC
1fdea7f69d Merge branch 'parallel_medias' into 'main'
feat: async upload of medias

See merge request veretcle/oolatoocs!3
2023-11-16 08:34:56 +00:00
VC
b73d6340c9 feat: async upload of medias 2023-11-15 15:20:03 +01:00
VC
00ba8bda42 Merge branch 'feat_other_medias' into 'main'
feat: separate metadata create + modify upload media for simple media only

See merge request veretcle/oolatoocs!2
2023-11-11 12:16:32 +00:00
VC
6d208f3de3 tamerelol 2023-11-11 12:59:57 +01:00
VC
b0c9485c82 Merge branch 'main' into 'feat_other_medias'
# Conflicts:
#   Cargo.lock
#   Cargo.toml
#   src/lib.rs
#   src/twitter.rs
2023-11-11 11:40:57 +00:00
VC
af7156786b chore: bump version to v1.0.0 2023-11-11 12:16:57 +01:00
VC
b9179d8cce feat: threads 2023-11-11 12:16:18 +01:00
VC
eba13ba095 feat: add video/gif medias 2023-11-10 19:29:29 +01:00
VC
f4c0504c28 Merge branch 'feat_medias' into 'main'
feat: attach medias to tweets

See merge request veretcle/oolatoocs!1
2023-11-09 17:52:56 +00:00
VC
dc9ed538f4 feat: attach medias to tweets 2023-11-09 18:44:31 +01:00
13 changed files with 3157 additions and 1026 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
/target
.last_tweet
.config.toml
.config.json

2982
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,23 +1,29 @@
[package]
name = "oolatoocs"
version = "0.1.0"
version = "4.1.4"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
chrono = "^0.4"
clap = "^4"
dissolve = "0.2.2"
env_logger = "^0.10"
env_logger = "^0.11"
futures = "^0.3"
html-escape = "^0.2"
log = "^0.4"
megalodon = "^0.11"
megalodon = "^1.0"
oauth1-request = "^0.6"
regex = "1.10.2"
reqwest = { version = "0.11.22", features = ["json"] }
rusqlite = "^0.27"
regex = "^1.10"
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"
image = "^0.25"
webp = "^0.3"
[profile.release]
strip = true

81
README.md Normal file
View File

@@ -0,0 +1,81 @@
# oolatoocs, a Mastodon to Bluesky bot
## A little bit of history
So what is it? Originally, I wrote, with some help, [Scootaloo](https://framagit.org/veretcle/scootaloo/) which was a Twitter to Mastodon Bot to help the [writers at NintendojoFR](https://www.nintendojo.fr) not to worry about Mastodon: the vast majority of writers were posting to Twitter, the bot scooped everything and arranged it properly for Mastodon and everything was fine and dandy. It was also used, in an altered beefed-up version, for the (now defunct) Mastodon Instance [Nupes.social](https://nupes.social) to make the tweets from the NUPES political alliance on Twitter, more easily accessible for Mastodon users.
But then Elon came, and we couldnt read data from Twitter anymore. So we had to rely on copy/pasting things from one to another, which is not fun nor efficient.
## And now…
Hence `oolatoocs`, which takes a Mastodon Timeline and reposts it to Bluesky as properly as possible.
Since 2025-01-20, Twitter is now longer supported.
# Remarkable features
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
* ⚠️ 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.
* 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
# Configuration file
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
[mastodon] # This part can be generated, see below
base = "https://m.nintendojo.fr"
client_id = "<REDACTED>"
client_secret = "<REDACTED>"
redirect = "urn:ietf:wg:oauth:2.0:oob"
token = "<REDACTED>"
[bluesky] # this is your Bsky handle and password + a writable path for the session handling
handle = "nintendojofr.bsky.social"
password = "<REDACTED>"
config_path = "/var/lib/oolatoocs/bsky.json"
```
## How to generate the Mastodon keys?
Just run:
```bash
oolatoocs register --host https://<your-instance>
```
And follow the instructions.
## How to generate the Bluesky part?
Youll need your handle and password. I strongly recommend a dedicated application password. Youll also need a writable path to store the Bsky session.
# How to run
First of all, the `--help`:
```bash
A Mastodon to Twitter Bot
Usage: oolatoocs [OPTIONS] [COMMAND]
Commands:
init Command to init the DB
register Command to register to Mastodon Instance
help Print this message or the help of the given subcommand(s)
Options:
-c, --config <CONFIG_FILE> TOML config file for oolatoocs [default: /usr/local/etc/oolatoocs.toml]
-h, --help Print help
-V, --version Print version
```
Ideally, youll put it an cron (from a non-root user), with the default path for config file and let it do its job. Yeah, thats it.

280
src/bsky.rs Normal file
View File

@@ -0,0 +1,280 @@
use crate::config::BlueskyConfig;
use atrium_api::{
app::bsky::feed::post::RecordData, com::atproto::repo::upload_blob::Output,
types::string::Datetime, types::string::Language,
};
use bsky_sdk::{
agent::config::{Config, FileStore},
rich_text::RichText,
BskyAgent,
};
use futures::{stream, StreamExt};
use image::ImageReader;
use log::{debug, error, warn};
use megalodon::entities::attachment::{Attachment, AttachmentType};
use regex::Regex;
use std::{error::Error, fs::exists, io::Cursor};
use webp::*;
/// Intermediary struct to deal with replies more easily
#[derive(Debug)]
pub struct BskyReply {
pub record_uri: String,
pub root_record_uri: String,
}
pub async fn get_session(config: &BlueskyConfig) -> Result<BskyAgent, Box<dyn Error>> {
if exists(&config.config_path)? {
let bluesky = BskyAgent::builder()
.config(Config::load(&FileStore::new(&config.config_path)).await?)
.build()
.await?;
if bluesky.api.com.atproto.server.get_session().await.is_ok() {
bluesky
.to_config()
.await
.save(&FileStore::new(&config.config_path))
.await?;
return Ok(bluesky);
}
}
let bluesky = BskyAgent::builder().build().await?;
bluesky.login(&config.handle, &config.password).await?;
bluesky
.to_config()
.await
.save(&FileStore::new(&config.config_path))
.await?;
Ok(bluesky)
}
pub async fn build_post_record(
config: &BlueskyConfig,
text: &str,
language: &Option<String>,
embed: Option<atrium_api::types::Union<atrium_api::app::bsky::feed::post::RecordEmbedRefs>>,
reply_to: &Option<BskyReply>,
) -> Result<RecordData, Box<dyn Error>> {
let mut rt = RichText::new_with_detect_facets(text).await?;
let insert_chars = "";
let re = Regex::new(r#"(https?://)(www\.)?(\S{1,26})(\S*)"#).unwrap();
while let Some(found) = re.captures(&rt.text.clone()) {
if let Some(group) = found.get(4) {
if !group.is_empty() {
rt.insert(group.start(), insert_chars);
rt.delete(
group.start() + insert_chars.len(),
group.start() + insert_chars.len() + group.len(),
);
}
}
if let Some(group) = found.get(1) {
let www: usize = found.get(2).map_or(0, |x| x.len());
rt.delete(group.start(), group.start() + www + group.len());
}
}
let langs = language.clone().map(|s| vec![Language::new(s).unwrap()]);
let reply = if let Some(x) = reply_to {
let root_record = get_record(&config.handle, &rkey(&x.root_record_uri)).await?;
let parent_record = get_record(&config.handle, &rkey(&x.record_uri)).await?;
Some(
atrium_api::app::bsky::feed::post::ReplyRefData {
parent: atrium_api::com::atproto::repo::strong_ref::MainData {
cid: parent_record.data.cid.unwrap(),
uri: parent_record.data.uri.to_owned(),
}
.into(),
root: atrium_api::com::atproto::repo::strong_ref::MainData {
cid: root_record.data.cid.unwrap(),
uri: root_record.data.uri.to_owned(),
}
.into(),
}
.into(),
)
} else {
None
};
Ok(RecordData {
created_at: Datetime::now(),
embed,
entities: None,
facets: rt.facets,
labels: None,
langs,
reply,
tags: None,
text: rt.text,
})
}
async fn get_record(
config: &str,
rkey: &str,
) -> Result<
atrium_api::types::Object<atrium_api::com::atproto::repo::get_record::OutputData>,
Box<dyn Error>,
> {
let bsky = BskyAgent::builder().build().await?;
let record = bsky
.api
.com
.atproto
.repo
.get_record(
atrium_api::com::atproto::repo::get_record::ParametersData {
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(),
}
.into(),
)
.await?;
Ok(record)
}
// its ugly af but it gets the job done for now
pub async fn generate_media_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)
.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: i.data.blob,
}
})
})
})
.buffered(4);
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(
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedImagesMain(Box::new(
atrium_api::app::bsky::embed::images::MainData { images }.into(),
)),
));
}
embed
}
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 {
dl.bytes().await?.as_ref().to_vec()
} else {
// this is an image and its over 1Mb long
debug!("Img file too large: {}", content_length);
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);
webp.to_vec()
};
let record = bsky.api.com.atproto.repo.upload_blob(bytes).await?;
Ok(record)
}
fn rkey(record_id: &str) -> String {
record_id.split('/').nth(4).unwrap().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_build_post_record() {
let text = "@factornews@piaille.fr Retrouvez-nous ici https://www.nintendojo.fr/articles/editos/le-mod-renovation-de-8bitdo-pour-manette-n64 et là https://www.nintendojo.fr/articles/analyses/vite-vu/vite-vu-morbid-the-lords-of-ire et un lien très court http://vsl.ie/TaMere et un autre https://p.nintendojo.fr/w/kV3CBbKKt1nPEChHhZiNve + http://www.xxx.com + https://www.youtube.com/watch?v=dQw4w9WgXcQ&pp=ygUJcmljayByb2xs";
let expected_text = "@factornews@piaille.fr Retrouvez-nous ici nintendojo.fr/articles/edi… et là nintendojo.fr/articles/ana… et un lien très court vsl.ie/TaMere et un autre p.nintendojo.fr/w/kV3CBbKK… + xxx.com + youtube.com/watch?v=dQw4w9…";
let bsky_conf = BlueskyConfig {
handle: "tamerelol.bsky.social".to_string(),
password: "dtc".to_string(),
config_path: "nope".to_string(),
};
let created_record_data = build_post_record(&bsky_conf, text, &None, None, &None)
.await
.unwrap();
assert_eq!(expected_text, &created_record_data.text);
}
}

View File

@@ -5,15 +5,7 @@ use std::fs::read_to_string;
pub struct Config {
pub oolatoocs: OolatoocsConfig,
pub mastodon: MastodonConfig,
pub twitter: TwitterConfig,
}
#[derive(Debug, Deserialize)]
pub struct TwitterConfig {
pub consumer_key: String,
pub consumer_secret: String,
pub oauth_token: String,
pub oauth_token_secret: String,
pub bluesky: BlueskyConfig,
}
#[derive(Debug, Deserialize)]
@@ -30,6 +22,13 @@ pub struct MastodonConfig {
pub token: String,
}
#[derive(Debug, Deserialize)]
pub struct BlueskyConfig {
pub handle: String,
pub password: String,
pub config_path: String,
}
/// parses TOML file into Config struct
pub fn parse_toml(toml_file: &str) -> Config {
let toml_config =

25
src/error.rs Normal file
View File

@@ -0,0 +1,25 @@
use std::{
error::Error,
fmt::{Display, Formatter, Result},
};
#[derive(Debug)]
pub struct OolatoocsError {
details: String,
}
impl OolatoocsError {
pub fn new(msg: &str) -> OolatoocsError {
OolatoocsError {
details: msg.to_string(),
}
}
}
impl Error for OolatoocsError {}
impl Display for OolatoocsError {
fn fmt(&self, f: &mut Formatter) -> Result {
write!(f, "{}", self.details)
}
}

View File

@@ -1,21 +1,24 @@
use log::debug;
mod error;
pub use error::OolatoocsError;
mod config;
pub use config::{parse_toml, Config};
mod state;
pub use state::init_db;
#[allow(unused_imports)]
use state::{read_state, write_state, TweetToToot};
use state::{delete_state, read_all_state, read_state, write_state, TootRecord};
pub use state::{init_db, migrate_db};
mod mastodon;
use mastodon::get_mastodon_timeline_since;
pub use mastodon::register;
use mastodon::{get_mastodon_instance, get_mastodon_timeline_since, get_status_edited_at};
mod utils;
use utils::strip_everything;
use utils::{generate_multi_tweets, strip_everything};
mod twitter;
#[allow(unused_imports)]
use twitter::{post_tweet, upload_media};
mod bsky;
use bsky::{build_post_record, generate_media_records, get_session, BskyReply};
use rusqlite::Connection;
@@ -24,31 +27,163 @@ pub async fn run(config: &Config) {
let conn = Connection::open(&config.oolatoocs.db_path)
.unwrap_or_else(|e| panic!("Cannot open DB: {}", e));
let last_toot_id = read_state(&conn, None)
.unwrap_or_else(|e| panic!("Cannot get last toot id: {}", e))
.map(|r| r.toot_id);
let mastodon = get_mastodon_instance(&config.mastodon)
.unwrap_or_else(|e| panic!("Cannot instantiate Mastodon: {}", e));
let timeline = get_mastodon_timeline_since(&config.mastodon, last_toot_id)
let bluesky = get_session(&config.bluesky)
.await
.unwrap_or_else(|e| panic!("Cannot get Bsky session: {}", e));
let last_entry =
read_state(&conn, None).unwrap_or_else(|e| panic!("Cannot get last toot id: {}", e));
let last_toot_id: Option<u64> = match last_entry {
None => None, // Does not exist, this is the same as previously
Some(t) => {
match get_status_edited_at(&mastodon, t.toot_id).await {
None => Some(t.toot_id),
Some(d) => {
// a date has been found
if d > t.datetime.unwrap() {
debug!("Last toot date is posterior to the previously written tweet, deleting…");
let local_record_uris =
read_all_state(&conn, t.toot_id).unwrap_or_else(|e| {
panic!(
"Cannot fetch all records associated with Toot ID {}: {}",
t.toot_id, e
)
});
for local_record_uri in local_record_uris.into_iter() {
bluesky
.delete_record(&local_record_uri)
.await
.unwrap_or_else(|e| {
panic!("Cannot delete record ID ({}): {}", &t.record_uri, e)
});
}
delete_state(&conn, t.toot_id).unwrap_or_else(|e| {
panic!("Cannot delete Toot ID ({}): {}", t.toot_id, e)
});
read_state(&conn, None)
.unwrap_or_else(|e| panic!("Cannot get last toot id: {}", e))
.map(|a| a.toot_id)
} else {
Some(t.toot_id)
}
}
}
}
};
let timeline = get_mastodon_timeline_since(&mastodon, last_toot_id)
.await
.unwrap_or_else(|e| panic!("Cannot get instance: {}", e));
for toot in timeline {
let Ok(tweet_content) = strip_everything(&toot.content, &toot.tags) else {
// detecting tag #NoTweet and skipping the toot
if toot.tags.iter().any(|f| &f.name == "notweet") {
continue;
}
// form tweet_content and strip everything useless in it
let Ok(mut tweet_content) = strip_everything(&toot.content, &toot.tags) else {
continue; // skip in case we cant strip something
};
// if we wanted to cut toot in half, now would be the right time to do so
// treating medias (nothing for now)
let tweet_id = post_tweet(&config.twitter, &tweet_content, &[])
.await
.unwrap_or_else(|e| panic!("Cannot Tweet {}: {}", toot.id, e));
// threads if necessary
let mut record_reply_to = toot.in_reply_to_id.and_then(|t| {
read_state(&conn, Some(t.parse::<u64>().unwrap()))
.ok()
.flatten()
.map(|s| BskyReply {
record_uri: s.record_uri.to_owned(),
root_record_uri: s.root_record_uri.to_owned(),
})
});
// if the toot is too long, we cut it in half here
if let Some((first_half, second_half)) = generate_multi_tweets(&tweet_content) {
tweet_content = second_half;
// post the first half
let record = build_post_record(
&config.bluesky,
&first_half,
&toot.language,
None,
&record_reply_to,
)
.await
.unwrap_or_else(|e| panic!("Cannot create valid record for {}: {}", &toot.id, e));
let record_reply_id = bluesky.create_record(record).await.unwrap_or_else(|e| {
panic!(
"Cannot post the first half of {} for Bluesky: {}",
&toot.id, e
)
});
// write it to db
write_state(
&conn,
TootRecord {
toot_id: toot.id.parse::<u64>().unwrap(),
record_uri: record_reply_id.data.uri.to_owned(),
root_record_uri: record_reply_to
.as_ref()
.map_or(record_reply_id.data.uri.to_owned(), |v| {
v.root_record_uri.to_owned()
}),
datetime: None,
},
)
.unwrap_or_else(|e| {
panic!(
"Cannot store Toot/Tweet/Record ({}/{}): {}",
&toot.id, &record_reply_id.data.uri, e
)
});
record_reply_to = Some(BskyReply {
record_uri: record_reply_id.data.uri.to_owned(),
root_record_uri: record_reply_to
.as_ref()
.map_or(record_reply_id.data.uri.clone(), |v| {
v.root_record_uri.clone()
}),
});
};
// treats medias
let record_medias = generate_media_records(&bluesky, &toot.media_attachments).await;
// posts corresponding tweet
let record = build_post_record(
&config.bluesky,
&tweet_content,
&toot.language,
record_medias,
&record_reply_to,
)
.await
.unwrap_or_else(|e| panic!("Cannot build record for {}: {}", &toot.id, e));
let created_record = bluesky
.create_record(record)
.await
.unwrap_or_else(|e| panic!("Cannot put record {}: {}", &toot.id, e));
// writes the current state of the tweet
write_state(
&conn,
TweetToToot {
tweet_id,
TootRecord {
toot_id: toot.id.parse::<u64>().unwrap(),
record_uri: created_record.data.uri.clone(),
root_record_uri: record_reply_to
.as_ref()
.map_or(created_record.data.uri.clone(), |v| {
v.root_record_uri.clone()
}),
datetime: None,
},
)
.unwrap_or_else(|e| panic!("Cannot store Toot/Tweet ({}/{}): {}", &toot.id, tweet_id, e));
.unwrap_or_else(|e| panic!("Cannot store Toot/Tweet ({}): {}", &toot.id, e));
}
}

View File

@@ -49,6 +49,21 @@ fn main() {
.display_order(1),
),
)
.subcommand(
Command::new("migrate")
.version(env!("CARGO_PKG_VERSION"))
.about("Command to register to Mastodon Instance")
.arg(
Arg::new("config")
.short('c')
.long("config")
.value_name("CONFIG_FILE")
.help(format!("TOML config file for {}", env!("CARGO_PKG_NAME")))
.num_args(1)
.default_value(DEFAULT_CONFIG_PATH)
.display_order(1),
),
)
.get_matches();
env_logger::init();
@@ -63,6 +78,11 @@ fn main() {
register(sub_m.get_one::<String>("host").unwrap());
return;
}
Some(("migrate", sub_m)) => {
let config = parse_toml(sub_m.get_one::<String>("config").unwrap());
migrate_db(&config.oolatoocs.db_path).unwrap();
return;
}
_ => (),
}

View File

@@ -1,4 +1,5 @@
use crate::config::MastodonConfig;
use chrono::{DateTime, Utc};
use megalodon::{
entities::{Status, StatusVisibility},
generator,
@@ -10,16 +11,29 @@ use megalodon::{
use std::error::Error;
use std::io::stdin;
pub async fn get_mastodon_timeline_since(
config: &MastodonConfig,
id: Option<u64>,
) -> Result<Vec<Status>, Box<dyn Error>> {
let mastodon = Mastodon::new(
/// Get Mastodon Object instance
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
pub async fn get_status_edited_at(mastodon: &Mastodon, t: u64) -> Option<DateTime<Utc>> {
mastodon
.get_status(t.to_string())
.await
.ok()
.and_then(|t| t.json.edited_at)
}
/// Get the home timeline since the last toot
pub async fn get_mastodon_timeline_since(
mastodon: &Mastodon,
id: Option<u64>,
) -> Result<Vec<Status>, Box<dyn Error>> {
let input_options = GetHomeTimelineInputOptions {
only_media: Some(false),
limit: None,
@@ -34,7 +48,6 @@ pub async fn get_mastodon_timeline_since(
.await?
.json()
.iter()
.cloned()
.filter(|t| {
// this excludes the reply to other users
t.in_reply_to_account_id.is_none()
@@ -45,6 +58,7 @@ pub async fn get_mastodon_timeline_since(
.filter(|t| t.visibility == StatusVisibility::Public) // excludes everything that isnt
// public
.filter(|t| t.reblog.is_none()) // excludes reblogs
.cloned()
.collect();
timeline.reverse();
@@ -57,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,

View File

@@ -1,33 +1,67 @@
use chrono::{DateTime, Utc};
use log::debug;
use rusqlite::{params, Connection, OptionalExtension};
use std::error::Error;
/// Struct for each query line
#[derive(Debug)]
pub struct TweetToToot {
pub tweet_id: u64,
pub struct TootRecord {
// Mastodon part
pub toot_id: u64,
// Bluesky part
pub record_uri: String,
pub root_record_uri: String,
pub datetime: Option<DateTime<Utc>>,
}
/// Deletes a given state
pub fn delete_state(conn: &Connection, toot_id: u64) -> Result<(), Box<dyn Error>> {
debug!("Deleting Toot ID {}", toot_id);
conn.execute(
&format!("DELETE FROM toot_record WHERE toot_id = {}", toot_id),
[],
)?;
Ok(())
}
/// Retrieves all tweets associated to a toot in the form of a vector
pub fn read_all_state(conn: &Connection, toot_id: u64) -> Result<Vec<String>, Box<dyn Error>> {
let query = format!(
"SELECT record_uri FROM toot_record WHERE toot_id = {};",
toot_id
);
let mut stmt = conn.prepare(&query)?;
let mut rows = stmt.query([])?;
let mut record_v: Vec<String> = Vec::new();
while let Some(row) = rows.next()? {
record_v.push(row.get(0)?);
}
Ok(record_v)
}
/// if None is passed, read the last tweet from DB
/// if a tweet_id is passed, read this particular tweet from DB
pub fn read_state(
conn: &Connection,
s: Option<u64>,
) -> Result<Option<TweetToToot>, Box<dyn Error>> {
pub fn read_state(conn: &Connection, s: Option<u64>) -> Result<Option<TootRecord>, Box<dyn Error>> {
debug!("Reading toot_id {:?}", s);
let begin_query = "SELECT *, UNIXEPOCH(datetime) AS unix_datetime FROM toot_record";
let query: String = match s {
Some(i) => format!("SELECT * FROM tweet_to_toot WHERE toot_id = {i}"),
None => "SELECT * FROM tweet_to_toot ORDER BY toot_id DESC LIMIT 1".to_string(),
Some(i) => format!("{begin_query} WHERE toot_id = {i} ORDER BY record_uri DESC LIMIT 1"),
None => format!("{begin_query} ORDER BY toot_id DESC LIMIT 1"),
};
let mut stmt = conn.prepare(&query)?;
let t = stmt
.query_row([], |row| {
Ok(TweetToToot {
tweet_id: row.get("tweet_id")?,
Ok(TootRecord {
toot_id: row.get("toot_id")?,
record_uri: row.get("record_uri")?,
root_record_uri: row.get("root_record_uri")?,
datetime: Some(
DateTime::from_timestamp(row.get("unix_datetime").unwrap(), 0).unwrap(),
),
})
})
.optional()?;
@@ -36,11 +70,11 @@ pub fn read_state(
}
/// Writes last treated tweet id and toot id to the db
pub fn write_state(conn: &Connection, t: TweetToToot) -> Result<(), Box<dyn Error>> {
pub fn write_state(conn: &Connection, t: TootRecord) -> Result<(), Box<dyn Error>> {
debug!("Write struct {:?}", t);
conn.execute(
"INSERT INTO tweet_to_toot (tweet_id, toot_id) VALUES (?1, ?2)",
params![t.tweet_id, t.toot_id],
"INSERT INTO toot_record (toot_id, record_uri, root_record_uri) VALUES (?1, ?2, ?3)",
params![t.toot_id, t.record_uri, t.root_record_uri],
)?;
Ok(())
@@ -55,9 +89,11 @@ pub fn init_db(d: &str) -> Result<(), Box<dyn Error>> {
let conn = Connection::open(d)?;
conn.execute(
"CREATE TABLE IF NOT EXISTS tweet_to_toot (
tweet_id INTEGER,
toot_id INTEGER PRIMARY KEY
"CREATE TABLE IF NOT EXISTS toot_record (
toot_id INTEGER,
record_uri VARCHAR(128) PRIMARY KEY,
root_record_uri VARCHAR(128) DEFAULT '',
datetime INTEGER DEFAULT CURRENT_TIMESTAMP
)",
[],
)?;
@@ -65,6 +101,55 @@ pub fn init_db(d: &str) -> Result<(), Box<dyn Error>> {
Ok(())
}
/// Migrate DB from 3+ to 4+
pub fn migrate_db(d: &str) -> Result<(), Box<dyn Error>> {
debug!("Migration DB for Oolatoocs");
let conn = Connection::open(d)?;
let res = conn.execute("SELECT datetime FROM toot_record;", []);
// If the column can be selected then, its OK
// if not, see if the error is a missing column and add it
match res {
Err(e) => match e.to_string().as_str() {
"no such table: toot_record" => migrate_db_alter_table(&conn), // table does not exist
"Execute returned results - did you mean to call query?" => Ok(()), // return results,
// column does
// exist
_ => Err(e.into()),
},
Ok(_) => Ok(()),
}
}
/// Creates a new table, copy the data from the old table and rename it
fn migrate_db_alter_table(c: &Connection) -> Result<(), Box<dyn Error>> {
// create the new table
c.execute(
"CREATE TABLE IF NOT EXISTS toot_record (
toot_id INTEGER,
record_uri VARCHAR(128) PRIMARY KEY,
root_record_uri VARCHAR(128) DEFAULT '',
datetime INTEGER DEFAULT CURRENT_TIMESTAMP
)",
[],
)?;
// copy data from the old table
c.execute(
"INSERT INTO toot_record (toot_id, record_uri, root_record_uri, datetime)
SELECT toot_id, record_uri, root_record_uri, datetime FROM toot_tweet_record
WHERE record_uri != '';",
[],
)?;
// drop the old table
c.execute("DROP TABLE IF EXISTS toot_tweet_record;", [])?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -81,7 +166,7 @@ mod tests {
// open said file
let conn = Connection::open(d).unwrap();
conn.execute("SELECT * from tweet_to_toot;", []).unwrap();
conn.execute("SELECT * from toot_record;", []).unwrap();
remove_file(d).unwrap();
}
@@ -96,9 +181,9 @@ mod tests {
let conn = Connection::open(d).unwrap();
conn.execute(
"INSERT INTO tweet_to_toot (tweet_id, toot_id)
"INSERT INTO toot_record (record_uri, toot_id)
VALUES
(100, 1001);",
('a', 1001);",
[],
)
.unwrap();
@@ -116,26 +201,35 @@ mod tests {
let conn = Connection::open(d).unwrap();
let t_in = TweetToToot {
tweet_id: 123456789,
let t_in = TootRecord {
toot_id: 987654321,
record_uri: "a".to_string(),
root_record_uri: "c".to_string(),
datetime: None,
};
write_state(&conn, t_in).unwrap();
let mut stmt = conn.prepare("SELECT * FROM tweet_to_toot;").unwrap();
let mut stmt = conn
.prepare("SELECT *, UNIXEPOCH(datetime) AS unix_datetime FROM toot_record;")
.unwrap();
let t_out = stmt
.query_row([], |row| {
Ok(TweetToToot {
tweet_id: row.get("tweet_id").unwrap(),
Ok(TootRecord {
toot_id: row.get("toot_id").unwrap(),
record_uri: row.get("record_uri").unwrap(),
root_record_uri: row.get("root_record_uri").unwrap(),
datetime: Some(
DateTime::from_timestamp(row.get("unix_datetime").unwrap(), 0).unwrap(),
),
})
})
.unwrap();
assert_eq!(t_out.tweet_id, 123456789);
assert_eq!(t_out.toot_id, 987654321);
assert_eq!(t_out.record_uri, "a".to_string());
assert_eq!(t_out.root_record_uri, "c".to_string());
remove_file(d).unwrap();
}
@@ -149,10 +243,10 @@ mod tests {
let conn = Connection::open(d).unwrap();
conn.execute(
"INSERT INTO tweet_to_toot (tweet_id, toot_id)
"INSERT INTO toot_record (toot_id, record_uri)
VALUES
(101, 1001),
(102, 1002);",
(101, 'abc'),
(102, 'def');",
[],
)
.unwrap();
@@ -161,8 +255,8 @@ mod tests {
remove_file(d).unwrap();
assert_eq!(t_out.tweet_id, 102);
assert_eq!(t_out.toot_id, 1002);
assert_eq!(t_out.toot_id, 102);
assert_eq!(t_out.record_uri, "def".to_string());
}
#[test]
@@ -189,9 +283,9 @@ mod tests {
let conn = Connection::open(d).unwrap();
conn.execute(
"INSERT INTO tweet_to_toot (tweet_id, toot_id)
"INSERT INTO toot_record (toot_id, record_uri)
VALUES
(100, 1000);",
(100, 'abc');",
[],
)
.unwrap();
@@ -212,9 +306,34 @@ mod tests {
let conn = Connection::open(d).unwrap();
conn.execute(
"INSERT INTO tweet_to_toot (tweet_id, toot_id)
"INSERT INTO toot_record (toot_id, record_uri)
VALUES
(100, 1000);",
(100, 'abc');",
[],
)
.unwrap();
let t_out = read_state(&conn, Some(100)).unwrap().unwrap();
remove_file(d).unwrap();
assert_eq!(t_out.toot_id, 100);
assert_eq!(t_out.record_uri, "abc".to_string());
}
#[test]
fn test_last_toot_id_read_state() {
let d = "/tmp/test_last_toot_id_read_state.sqlite";
init_db(d).unwrap();
let conn = Connection::open(d).unwrap();
conn.execute(
"INSERT INTO toot_record (toot_id, record_uri)
VALUES
(1000, 'abc'),
(1000, 'def');",
[],
)
.unwrap();
@@ -223,7 +342,126 @@ mod tests {
remove_file(d).unwrap();
assert_eq!(t_out.tweet_id, 100);
assert_eq!(t_out.toot_id, 1000);
assert_eq!(t_out.record_uri, "def".to_string());
}
#[test]
fn test_migrate_db() {
// this should be idempotent
let d = "/tmp/test_migrate_db.sqlite";
let conn = Connection::open(d).unwrap();
conn.execute(
"CREATE TABLE IF NOT EXISTS toot_tweet_record (
toot_id INTEGER,
tweet_id INTEGER PRIMARY KEY,
record_uri VARCHAR(128) DEFAULT '',
root_record_uri VARCHAR(128) DEFAULT '',
datetime INTEGER DEFAULT CURRENT_TIMESTAMP
)",
[],
)
.unwrap();
conn.execute(
"INSERT INTO toot_tweet_record (tweet_id, toot_id, record_uri) VALUES (0, 0, ''), (1, 1, 'abc');",
[],
)
.unwrap();
migrate_db(d).unwrap();
let last_state = read_state(&conn, None).unwrap().unwrap();
assert_eq!(last_state.toot_id, 1);
migrate_db(d).unwrap(); // shouldnt do anything
remove_file(d).unwrap();
}
#[test]
fn test_delete_state() {
let d = "/tmp/test_delete_state.sqlite";
init_db(d).unwrap();
let conn = Connection::open(d).unwrap();
conn.execute(
"INSERT INTO toot_record(toot_id, record_uri) VALUES (0, 'abc');",
[],
)
.unwrap();
delete_state(&conn, 0).unwrap();
let mut stmt = conn
.prepare("SELECT *, UNIXEPOCH(datetime) AS unix_datetime FROM toot_record;")
.unwrap();
let t_out = stmt.query_row([], |row| {
Ok(TootRecord {
toot_id: row.get("toot_id").unwrap(),
record_uri: row.get("record_uri").unwrap(),
root_record_uri: row.get("root_record_uri").unwrap(),
datetime: Some(
DateTime::from_timestamp(row.get("unix_datetime").unwrap(), 0).unwrap(),
),
})
});
assert!(t_out.is_err_and(|x| x == rusqlite::Error::QueryReturnedNoRows));
conn.execute(
"INSERT INTO toot_record(toot_id, record_uri) VALUES(42, 'abc'), (42, 'def');",
[],
)
.unwrap();
delete_state(&conn, 42).unwrap();
let mut stmt = conn
.prepare("SELECT *, UNIXEPOCH(datetime) AS unix_datetime FROM toot_record;")
.unwrap();
let t_out = stmt.query_row([], |row| {
Ok(TootRecord {
toot_id: row.get("toot_id").unwrap(),
record_uri: row.get("record_uri").unwrap(),
root_record_uri: row.get("root_record_uri").unwrap(),
datetime: Some(
DateTime::from_timestamp(row.get("unix_datetime").unwrap(), 0).unwrap(),
),
})
});
assert!(t_out.is_err_and(|x| x == rusqlite::Error::QueryReturnedNoRows));
remove_file(d).unwrap();
}
#[test]
fn test_read_all_state() {
let d = "/tmp/read_all_state.sqlite";
init_db(d).unwrap();
let conn = Connection::open(d).unwrap();
conn.execute(
"INSERT INTO toot_record (toot_id, record_uri) VALUES (42, 'abc'), (42, 'def'), (43, 'ghi');",
[],
)
.unwrap();
let record_v1 = read_all_state(&conn, 43).unwrap();
let record_v2 = read_all_state(&conn, 42).unwrap();
assert_eq!(record_v1, vec!["ghi".to_string()]);
assert_eq!(record_v2, vec!["abc".to_string(), "def".to_string()]);
remove_file(d).unwrap();
}
}

View File

@@ -1,70 +0,0 @@
use crate::config::TwitterConfig;
use oauth1_request::Token;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::error::Error;
/// I dont know, dont ask me
#[derive(oauth1_request::Request)]
struct EmptyRequest {}
#[derive(Serialize, Debug)]
pub struct Tweet {
pub text: String,
}
#[derive(Deserialize, Debug)]
pub struct TweetResponse {
pub data: TweetResponseData,
}
#[derive(Deserialize, Debug)]
pub struct TweetResponseData {
pub id: String,
}
/// This function returns the OAuth1 Token object from TwitterConfig
fn get_token(config: &TwitterConfig) -> Token {
oauth1_request::Token::from_parts(
config.consumer_key.to_string(),
config.consumer_secret.to_string(),
config.oauth_token.to_string(),
config.oauth_token_secret.to_string(),
)
}
/// This function uploads media from Mastodon to Twitter and returns the media id from Twitter
#[allow(dead_code)]
pub async fn upload_media(_u: &str) -> Result<u64, Box<dyn Error>> {
Ok(0)
}
/// This posts Tweets with all the associated medias
pub async fn post_tweet(
config: &TwitterConfig,
content: &str,
_medias: &[u64],
) -> Result<u64, Box<dyn Error>> {
let uri = "https://api.twitter.com/2/tweets";
let empty_request = EmptyRequest {}; // Why? Because fuck you, thats why!
let token = get_token(config);
let tweet = Tweet {
text: content.to_string(),
};
let client = Client::new();
let res = client
.post(uri)
.header(
"Authorization",
oauth1_request::post(uri, &empty_request, &token, oauth1_request::HMAC_SHA1),
)
.json(&tweet)
.send()
.await?
.json::<TweetResponse>()
.await?;
Ok(res.data.id.parse::<u64>().unwrap())
}

View File

@@ -1,15 +1,59 @@
use dissolve::strip_html_tags;
use html_escape::decode_html_entities;
use megalodon::entities::status::Tag;
use regex::Regex;
use std::error::Error;
/// Generate 2 contents out of 1 if that content is > 300 chars, None else
pub fn generate_multi_tweets(content: &str) -> Option<(String, String)> {
// Twitter webforms are utf-8 encoded, so we cannot count on len(), we dont need
// encode_utf16().count()
if twitter_count(content) <= 300 {
return None;
}
let split_content = content.split(' ');
let split_count = split_content.clone().count();
let first_half: String = split_content
.clone()
.take(split_count / 2)
.collect::<Vec<_>>()
.join(" ");
let second_half: String = split_content
.clone()
.skip(split_count / 2)
.collect::<Vec<_>>()
.join(" ");
Some((first_half, second_half))
}
/// Twitter doesnt count words the same we do, so youll have to improvise
fn twitter_count(content: &str) -> usize {
let mut count = 0;
let split_content = content.split(&[' ', '\n']);
count += split_content.clone().count() - 1; // count the spaces
for word in split_content {
if word.starts_with("http://") || word.starts_with("https://") {
count += 23;
} else {
count += word.chars().count();
}
}
count
}
pub fn strip_everything(content: &str, tags: &Vec<Tag>) -> Result<String, Box<dyn Error>> {
let mut res =
strip_html_tags(&content.replace("</p><p>", "\n\n").replace("<br />", "\n")).join("");
let mut res = strip_html_tags(&content.replace("</p><p>", "\n\n").replace("<br />", "\n"));
strip_mastodon_tags(&mut res, tags).unwrap();
res = res.trim_end_matches('\n').trim_end_matches(' ').to_string();
res = decode_html_entities(&res).to_string();
Ok(res)
}
@@ -22,3 +66,124 @@ fn strip_mastodon_tags(content: &mut String, tags: &Vec<Tag>) -> Result<(), Box<
Ok(())
}
fn strip_html_tags(input: &str) -> String {
let mut data = String::new();
let mut inside = false;
for c in input.chars() {
if c == '<' {
inside = true;
continue;
}
if c == '>' {
inside = false;
continue;
}
if !inside {
data.push(c);
}
}
data
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_twitter_count() {
let content = "tamerelol?! 🐵";
assert_eq!(twitter_count(content), content.chars().count());
let content = "Shoot out to https://y.ml/ !";
assert_eq!(twitter_count(content), 38);
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);
let content = "multi ple space";
assert_eq!(twitter_count(content), content.chars().count());
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);
}
#[test]
fn test_generate_multi_tweets_to_none() {
// test «standard» text
let tweet_content =
"LOLOLOL, je suis bien trop petit pour être coupé en deux voyons :troll:".to_string();
let youpi = generate_multi_tweets(&tweet_content);
assert_eq!(None, youpi);
// test with «complex» emoji (2 utf-8 chars)
let tweet_content = "🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷🇫🇷".to_string();
let youpi = generate_multi_tweets(&tweet_content);
assert_eq!(None, youpi);
// test with 299 chars
let tweet_content = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate vulver amico tio".to_string();
let youpi = generate_multi_tweets(&tweet_content);
assert_eq!(None, youpi);
}
#[test]
fn test_generate_multi_tweets_to_some() {
let tweet_content = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ipsum dolor sit amet consectetur adipiscing elit pellentesque. Pharetra pharetra massa massa ultricies mi quis hendrerit dolor. Mauris nunc congue nisi vitae. Scelerisque varius morbi enim nunc faucibus a pellentesque sit amet. Morbi leo urna molestie at elementum. Tristique et egestas quis ipsum suspendisse ultrices gravida dictum fusce. Amet porttitor eget dolor morbi.".to_string();
let youpi = generate_multi_tweets(&tweet_content);
let first_half = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ipsum dolor sit amet consectetur adipiscing elit pellentesque. Pharetra pharetra massa massa ultricies mi quis hendrerit dolor.".to_string();
let second_half = "Mauris nunc congue nisi vitae. Scelerisque varius morbi enim nunc faucibus a pellentesque sit amet. Morbi leo urna molestie at elementum. Tristique et egestas quis ipsum suspendisse ultrices gravida dictum fusce. Amet porttitor eget dolor morbi.".to_string();
assert_eq!(youpi, Some((first_half, second_half)));
}
#[test]
fn test_strip_mastodon_tags() {
let tags = vec![
Tag {
name: "putaclic".to_string(),
url: "https://m.nintendojo.fr/tags/putaclic".to_string(),
},
Tag {
name: "tamerelol".to_string(),
url: "https://m.nintendojo.fr/tags/tamerelol".to_string(),
},
Tag {
name: "JeFaisNawakEnCamelCase".to_string(),
url: "https://m.nintendojo.fr/tags/jefaisnawakencamelcase".to_string(),
},
];
let mut content =
"Cest super ça! #putaclic #TAMERELOL #JeFaisNawakEnCamelCase".to_string();
let sample = "Cest super ça! ".to_string();
strip_mastodon_tags(&mut content, &tags).unwrap();
assert_eq!(content, sample);
}
#[test]
fn test_strip_everything() {
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();
assert_eq!(result, expected_result);
}
}