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