Initial commit

This commit is contained in:
VC
2023-11-07 14:19:40 +01:00
commit e8c2d35e21
9 changed files with 2644 additions and 0 deletions

3
.gitignore vendored Normal file
View File

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

5
.gitlab-ci.yml Normal file
View File

@@ -0,0 +1,5 @@
---
include:
project: 'veretcle/ci-common'
ref: 'main'
file: 'ci_rust.yml'

2143
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

23
Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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 lets 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);
}
}