mirror of
https://framagit.org/veretcle/oolatoocs.git
synced 2025-07-20 12:31:18 +02:00
Initial commit
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/target
|
||||||
|
.last_tweet
|
||||||
|
.config.toml
|
5
.gitlab-ci.yml
Normal file
5
.gitlab-ci.yml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
include:
|
||||||
|
project: 'veretcle/ci-common'
|
||||||
|
ref: 'main'
|
||||||
|
file: 'ci_rust.yml'
|
2143
Cargo.lock
generated
Normal file
2143
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
Cargo.toml
Normal file
23
Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
[package]
|
||||||
|
name = "oolatoocs"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clap = "^4"
|
||||||
|
env_logger = "^0.10"
|
||||||
|
log = "^0.4"
|
||||||
|
megalodon = "^0.11"
|
||||||
|
oauth1-request = "^0.6"
|
||||||
|
reqwest = "^0.11"
|
||||||
|
rusqlite = "^0.27"
|
||||||
|
serde = { version = "^1.0", features = ["derive"] }
|
||||||
|
tokio = { version = "^1.33", features = ["rt-multi-thread", "macros"] }
|
||||||
|
toml = "^0.8"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
strip = true
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
42
src/config.rs
Normal file
42
src/config.rs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
use std::fs::read_to_string;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct OolatoocsConfig {
|
||||||
|
pub db_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct MastodonConfig {
|
||||||
|
pub base: String,
|
||||||
|
pub client_id: String,
|
||||||
|
pub client_secret: String,
|
||||||
|
pub redirect: String,
|
||||||
|
pub token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// parses TOML file into Config struct
|
||||||
|
pub fn parse_toml(toml_file: &str) -> Config {
|
||||||
|
let toml_config =
|
||||||
|
read_to_string(toml_file).unwrap_or_else(|e| panic!("Cannot open file {toml_file}: {e}"));
|
||||||
|
|
||||||
|
let config: Config = toml::from_str(&toml_config)
|
||||||
|
.unwrap_or_else(|e| panic!("Cannot parse TOML file {toml_file}: {e}"));
|
||||||
|
|
||||||
|
config
|
||||||
|
}
|
30
src/lib.rs
Normal file
30
src/lib.rs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
mod config;
|
||||||
|
pub use config::{parse_toml, Config};
|
||||||
|
|
||||||
|
mod state;
|
||||||
|
pub use state::init_db;
|
||||||
|
use state::read_state;
|
||||||
|
|
||||||
|
mod mastodon;
|
||||||
|
use mastodon::get_mastodon_timeline_since;
|
||||||
|
pub use mastodon::register;
|
||||||
|
|
||||||
|
use rusqlite::Connection;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
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 timeline = get_mastodon_timeline_since(&config.mastodon, last_toot_id)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| panic!("Cannot get instance: {}", e));
|
||||||
|
|
||||||
|
for toot in timeline {
|
||||||
|
println!("{:?}", &toot.content);
|
||||||
|
}
|
||||||
|
}
|
72
src/main.rs
Normal file
72
src/main.rs
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
use clap::{Arg, Command};
|
||||||
|
use oolatoocs::*;
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG_PATH: &str = "/usr/local/etc/oolatoocs.toml";
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let matches = Command::new(env!("CARGO_PKG_NAME"))
|
||||||
|
.version(env!("CARGO_PKG_VERSION"))
|
||||||
|
.about("A Mastodon to Twitter Bot")
|
||||||
|
.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),
|
||||||
|
)
|
||||||
|
.subcommand(
|
||||||
|
Command::new("init")
|
||||||
|
.version(env!("CARGO_PKG_VERSION"))
|
||||||
|
.about("Command to init the DB")
|
||||||
|
.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),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.subcommand(
|
||||||
|
Command::new("register")
|
||||||
|
.version(env!("CARGO_PKG_VERSION"))
|
||||||
|
.about("Command to register to Mastodon Instance")
|
||||||
|
.arg(
|
||||||
|
Arg::new("host")
|
||||||
|
.short('o')
|
||||||
|
.long("host")
|
||||||
|
.value_name("HOST")
|
||||||
|
.help(format!(
|
||||||
|
"Register {} to a Mastodon Instance",
|
||||||
|
env!("CARGO_PKG_NAME")
|
||||||
|
))
|
||||||
|
.num_args(1)
|
||||||
|
.display_order(1),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get_matches();
|
||||||
|
|
||||||
|
env_logger::init();
|
||||||
|
|
||||||
|
match matches.subcommand() {
|
||||||
|
Some(("init", sub_m)) => {
|
||||||
|
let config = parse_toml(sub_m.get_one::<String>("config").unwrap());
|
||||||
|
init_db(&config.oolatoocs.db_path).unwrap_or_else(|e| panic!("Cannot init DB: {}", e));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Some(("register", sub_m)) => {
|
||||||
|
register(sub_m.get_one::<String>("host").unwrap());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = parse_toml(matches.get_one::<String>("config").unwrap());
|
||||||
|
|
||||||
|
run(&config);
|
||||||
|
}
|
100
src/mastodon.rs
Normal file
100
src/mastodon.rs
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
use crate::config::MastodonConfig;
|
||||||
|
use megalodon::{
|
||||||
|
entities::Status, generator, mastodon::mastodon::Mastodon, megalodon::AppInputOptions,
|
||||||
|
megalodon::GetHomeTimelineInputOptions, 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(
|
||||||
|
config.base.to_string(),
|
||||||
|
Some(config.token.to_string()),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
let input_options = GetHomeTimelineInputOptions {
|
||||||
|
only_media: Some(false),
|
||||||
|
limit: None,
|
||||||
|
max_id: None,
|
||||||
|
since_id: id.map(|i| i.to_string()),
|
||||||
|
min_id: None,
|
||||||
|
local: Some(true),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut timeline: Vec<Status> = mastodon
|
||||||
|
.get_home_timeline(Some(&input_options))
|
||||||
|
.await?
|
||||||
|
.json()
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.filter(|t| {
|
||||||
|
// this excludes the reply to other users
|
||||||
|
t.in_reply_to_account_id.is_none()
|
||||||
|
|| t.in_reply_to_account_id
|
||||||
|
.clone()
|
||||||
|
.is_some_and(|r| r == t.account.id)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
timeline.reverse();
|
||||||
|
|
||||||
|
Ok(timeline)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generic register function
|
||||||
|
/// As this function is supposed to be run only once, it will panic for every error it encounters
|
||||||
|
/// 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 options = AppInputOptions {
|
||||||
|
redirect_uris: None,
|
||||||
|
scopes: Some(["read:statuses".to_string()].to_vec()),
|
||||||
|
website: Some("https://framagit.org/veretcle/oolatoocs".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let app_data = mastodon
|
||||||
|
.register_app(env!("CARGO_PKG_NAME").to_string(), &options)
|
||||||
|
.await
|
||||||
|
.expect("Cannot build registration object!");
|
||||||
|
|
||||||
|
let url = app_data.url.expect("Cannot generate registration URI!");
|
||||||
|
|
||||||
|
println!("Click this link to authorize on Mastodon: {url}");
|
||||||
|
println!("Paste the returned authorization code: ");
|
||||||
|
|
||||||
|
let mut input = String::new();
|
||||||
|
stdin()
|
||||||
|
.read_line(&mut input)
|
||||||
|
.expect("Unable to read back registration code!");
|
||||||
|
|
||||||
|
let token_data = mastodon
|
||||||
|
.fetch_access_token(
|
||||||
|
app_data.client_id.to_owned(),
|
||||||
|
app_data.client_secret.to_owned(),
|
||||||
|
input.trim().to_string(),
|
||||||
|
megalodon::default::NO_REDIRECT.to_string(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("Unable to create access token!");
|
||||||
|
|
||||||
|
println!(
|
||||||
|
r#"Please insert the following block at the end of your configuration file:
|
||||||
|
[mastodon]
|
||||||
|
base = "{}"
|
||||||
|
client_id = "{}"
|
||||||
|
client_secret = "{}"
|
||||||
|
redirect = "{}"
|
||||||
|
token = "{}""#,
|
||||||
|
host,
|
||||||
|
app_data.client_id,
|
||||||
|
app_data.client_secret,
|
||||||
|
app_data.redirect_uri.as_ref().unwrap(),
|
||||||
|
token_data.access_token,
|
||||||
|
);
|
||||||
|
}
|
226
src/state.rs
Normal file
226
src/state.rs
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
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 toot_id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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>> {
|
||||||
|
debug!("Reading tweet_id {:?}", s);
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut stmt = conn.prepare(&query)?;
|
||||||
|
|
||||||
|
let t = stmt
|
||||||
|
.query_row([], |row| {
|
||||||
|
Ok(TweetToToot {
|
||||||
|
tweet_id: row.get("tweet_id")?,
|
||||||
|
toot_id: row.get("toot_id")?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.optional()?;
|
||||||
|
|
||||||
|
Ok(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes last treated tweet id and toot id to the db
|
||||||
|
pub fn write_state(conn: &Connection, t: TweetToToot) -> 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],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initiates the DB from path
|
||||||
|
pub fn init_db(d: &str) -> Result<(), Box<dyn Error>> {
|
||||||
|
debug!("Initializing DB for Scootaloo");
|
||||||
|
let conn = Connection::open(d)?;
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS tweet_to_toot (
|
||||||
|
tweet_id INTEGER,
|
||||||
|
toot_id INTEGER PRIMARY KEY
|
||||||
|
)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::{fs::remove_file, path::Path};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_init_db() {
|
||||||
|
let d = "/tmp/test_init_db.sqlite";
|
||||||
|
|
||||||
|
init_db(d).unwrap();
|
||||||
|
|
||||||
|
// check that file exist
|
||||||
|
assert!(Path::new(d).exists());
|
||||||
|
|
||||||
|
// open said file
|
||||||
|
let conn = Connection::open(d).unwrap();
|
||||||
|
conn.execute("SELECT * from tweet_to_toot;", []).unwrap();
|
||||||
|
|
||||||
|
remove_file(d).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_init_init_db() {
|
||||||
|
// init_db fn should be idempotent so let’s test that
|
||||||
|
let d = "/tmp/test_init_init_db.sqlite";
|
||||||
|
|
||||||
|
init_db(d).unwrap();
|
||||||
|
|
||||||
|
let conn = Connection::open(d).unwrap();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO tweet_to_toot (tweet_id, toot_id)
|
||||||
|
VALUES
|
||||||
|
(100, 1001);",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
init_db(d).unwrap();
|
||||||
|
|
||||||
|
remove_file(d).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_write_state() {
|
||||||
|
let d = "/tmp/test_write_state.sqlite";
|
||||||
|
|
||||||
|
init_db(d).unwrap();
|
||||||
|
|
||||||
|
let conn = Connection::open(d).unwrap();
|
||||||
|
|
||||||
|
let t_in = TweetToToot {
|
||||||
|
tweet_id: 123456789,
|
||||||
|
toot_id: 987654321,
|
||||||
|
};
|
||||||
|
|
||||||
|
write_state(&conn, t_in).unwrap();
|
||||||
|
|
||||||
|
let mut stmt = conn.prepare("SELECT * FROM tweet_to_toot;").unwrap();
|
||||||
|
|
||||||
|
let t_out = stmt
|
||||||
|
.query_row([], |row| {
|
||||||
|
Ok(TweetToToot {
|
||||||
|
tweet_id: row.get("tweet_id").unwrap(),
|
||||||
|
toot_id: row.get("toot_id").unwrap(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(t_out.tweet_id, 123456789);
|
||||||
|
assert_eq!(t_out.toot_id, 987654321);
|
||||||
|
|
||||||
|
remove_file(d).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_none_to_tweet_id_read_state() {
|
||||||
|
let d = "/tmp/test_none_to_tweet_id_read_state.sqlite";
|
||||||
|
|
||||||
|
init_db(d).unwrap();
|
||||||
|
|
||||||
|
let conn = Connection::open(d).unwrap();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO tweet_to_toot (tweet_id, toot_id)
|
||||||
|
VALUES
|
||||||
|
(101, 1001),
|
||||||
|
(102, 1002);",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let t_out = read_state(&conn, None).unwrap().unwrap();
|
||||||
|
|
||||||
|
remove_file(d).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(t_out.tweet_id, 102);
|
||||||
|
assert_eq!(t_out.toot_id, 1002);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_none_to_none_read_state() {
|
||||||
|
let d = "/tmp/test_none_to_none_read_state.sqlite";
|
||||||
|
|
||||||
|
init_db(d).unwrap();
|
||||||
|
|
||||||
|
let conn = Connection::open(d).unwrap();
|
||||||
|
|
||||||
|
let t_out = read_state(&conn, None).unwrap();
|
||||||
|
|
||||||
|
remove_file(d).unwrap();
|
||||||
|
|
||||||
|
assert!(t_out.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tweet_id_to_none_read_state() {
|
||||||
|
let d = "/tmp/test_tweet_id_to_none_read_state.sqlite";
|
||||||
|
|
||||||
|
init_db(d).unwrap();
|
||||||
|
|
||||||
|
let conn = Connection::open(d).unwrap();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO tweet_to_toot (tweet_id, toot_id)
|
||||||
|
VALUES
|
||||||
|
(100, 1000);",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let t_out = read_state(&conn, Some(1200)).unwrap();
|
||||||
|
|
||||||
|
remove_file(d).unwrap();
|
||||||
|
|
||||||
|
assert!(t_out.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tweet_id_to_tweet_id_read_state() {
|
||||||
|
let d = "/tmp/test_tweet_id_to_tweet_id_read_state.sqlite";
|
||||||
|
|
||||||
|
init_db(d).unwrap();
|
||||||
|
|
||||||
|
let conn = Connection::open(d).unwrap();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO tweet_to_toot (tweet_id, toot_id)
|
||||||
|
VALUES
|
||||||
|
(100, 1000);",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let t_out = read_state(&conn, Some(1000)).unwrap().unwrap();
|
||||||
|
|
||||||
|
remove_file(d).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(t_out.tweet_id, 100);
|
||||||
|
assert_eq!(t_out.toot_id, 1000);
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user