/target | |||||
*~ |
[package] | |||||
name = "mtxspooler" | |||||
version = "0.1.0" | |||||
authors = [] | |||||
edition = "2018" | |||||
[dependencies] | |||||
clap = { git = "https://github.com/clap-rs/clap", features = ["derive"] } | |||||
futures = { version = "0.3.4", features = ["compat"] } | |||||
hyper = "0.13.2" | |||||
hyper-tls = "0.4.1" | |||||
inotify = "0.3.0" | |||||
rand = "0.7.3" | |||||
ruma-api = "0.13.0" | |||||
ruma-client = "0.3.0" | |||||
ruma-client-api = "0.6.0" | |||||
ruma-events = "0.15.0" | |||||
ruma-identifiers = "0.14.1" | |||||
tokio = { version = "0.2.16", features = ["fs", "io-driver", "net", "rt-core", "sync"] } | |||||
tokio-inotify = "0.4.1" | |||||
toml = "0.5.6" | |||||
url = "2.1.1" |
use std::collections::*; | |||||
use std::convert::TryFrom; | |||||
use std::default::Default; | |||||
use std::io; | |||||
use std::path::PathBuf; | |||||
use futures::prelude::*; | |||||
use ruma_identifiers::{self, RoomId}; | |||||
use tokio::fs as tokiofs; | |||||
use tokio::prelude::*; | |||||
use toml; | |||||
// FIXME Some of these error types are only used in the main module. | |||||
#[derive(Debug)] | |||||
pub enum Error { | |||||
// TODO Reconcile these different parse errors. | |||||
ParseFile(PathBuf), | |||||
ParseToml(toml::de::Error), | |||||
ParseMalformed, | |||||
ParseIdentifier, | |||||
Io(io::Error), | |||||
} | |||||
impl From<io::Error> for Error { | |||||
fn from(f: io::Error) -> Self { | |||||
Self::Io(f) | |||||
} | |||||
} | |||||
impl From<toml::de::Error> for Error { | |||||
fn from(f: toml::de::Error) -> Self { | |||||
Self::ParseToml(f) | |||||
} | |||||
} | |||||
#[derive(Default, Debug)] | |||||
pub struct Config { | |||||
pub accounts: HashMap<String, Account>, | |||||
pub spool_dirs: Vec<SpoolDir>, | |||||
} | |||||
#[derive(Clone, Debug)] | |||||
pub struct Account { | |||||
pub homeserver: String, | |||||
pub display: Option<String>, | |||||
pub device_id: Option<String>, | |||||
pub auth: Auth, | |||||
} | |||||
#[derive(Clone, Debug)] | |||||
pub enum Auth { | |||||
UsernamePass(String, String), | |||||
} | |||||
#[derive(Debug)] | |||||
pub struct SpoolDir { | |||||
pub path: PathBuf, | |||||
pub send_delay_sec: u32, | |||||
pub sender_acct_label: String, | |||||
pub dest_room_id: RoomId, | |||||
} | |||||
pub async fn find_configs(search_dir: &PathBuf) -> Result<Vec<PathBuf>, Error> { | |||||
let items: Vec<tokiofs::DirEntry> = tokiofs::read_dir(search_dir).await?.try_collect().await?; | |||||
Ok(items | |||||
.into_iter() | |||||
.filter(|de| { | |||||
de.file_name() | |||||
.to_str() | |||||
.map(|s| s.ends_with(".toml")) | |||||
.unwrap_or(false) | |||||
}) | |||||
.map(|e| e.path()) | |||||
.collect()) | |||||
} | |||||
pub async fn parse_configs(paths: Vec<PathBuf>) -> Result<Config, Error> { | |||||
let mut conf = Config::default(); | |||||
for p in &paths { | |||||
println!("Reading config: {}", p.to_str().unwrap_or("[non-UTF-8]")); | |||||
let val = match load_toml(p).await { | |||||
Ok(t) => t, | |||||
Err(_) => { | |||||
println!("Error loading config, skipping"); | |||||
continue; | |||||
} | |||||
}; | |||||
match parse_toml(&val) { | |||||
Ok((accts, spools)) => { | |||||
conf.accounts.extend(accts); | |||||
conf.spool_dirs.extend(spools); | |||||
} | |||||
Err(_) => { | |||||
println!("Error processing config, skipping"); | |||||
continue; | |||||
} | |||||
} | |||||
} | |||||
Ok(conf) | |||||
} | |||||
async fn load_toml(path: &PathBuf) -> Result<toml::Value, Error> { | |||||
let mut buf = Vec::new(); | |||||
let mut f = tokiofs::File::open(path).await?; | |||||
let _ = f.read_to_end(&mut buf).await?; | |||||
toml::de::from_slice(buf.as_slice()).map_err(|_| Error::ParseFile(path.clone())) | |||||
} | |||||
fn parse_toml(val: &toml::Value) -> Result<(Vec<(String, Account)>, Vec<SpoolDir>), Error> { | |||||
use toml::Value::*; | |||||
let mut accts = Vec::new(); | |||||
let mut spools = Vec::new(); | |||||
match val { | |||||
Table(tab) => { | |||||
let acct_ents = tab.get("acct").cloned(); | |||||
let watch_ents = tab.get("watch").cloned(); | |||||
match acct_ents { | |||||
Some(Array(entries)) => { | |||||
for acct in &entries { | |||||
accts.push(parse_acct_entry(&acct)?); | |||||
} | |||||
} | |||||
// TODO | |||||
Some(_) => {} | |||||
_ => {} | |||||
} | |||||
match watch_ents { | |||||
Some(Array(entries)) => { | |||||
for wd in &entries { | |||||
spools.push(parse_watch_entry(&wd)?); | |||||
} | |||||
} | |||||
// TODO | |||||
Some(_) => {} | |||||
_ => {} | |||||
} | |||||
} | |||||
_ => {} | |||||
} | |||||
Ok((accts, spools)) | |||||
} | |||||
fn parse_acct_entry(ent: &toml::Value) -> Result<(String, Account), Error> { | |||||
use toml::Value::*; | |||||
type StdString = ::std::string::String; | |||||
let label = ent.get("label"); | |||||
let homeserver = ent.get("homeserver"); | |||||
let display = ent.get("display"); | |||||
let dev_id = ent.get("deviceid"); | |||||
let username = ent.get("username"); | |||||
let password = ent.get("password"); | |||||
// This is gross and I don't like it, but ok. | |||||
match (label, homeserver, username, password) { | |||||
(Some(String(l)), Some(String(s)), Some(String(u)), Some(String(p))) => { | |||||
let auth = Auth::UsernamePass(u.clone(), p.clone()); | |||||
Ok(( | |||||
l.clone(), | |||||
Account { | |||||
homeserver: s.clone(), | |||||
display: display | |||||
.cloned() | |||||
.map(|v| v.try_into::<StdString>()) | |||||
.transpose()?, | |||||
device_id: dev_id | |||||
.cloned() | |||||
.map(|v| v.try_into::<StdString>()) | |||||
.transpose()?, | |||||
auth: auth, | |||||
}, | |||||
)) | |||||
} | |||||
_ => Err(Error::ParseMalformed), | |||||
} | |||||
} | |||||
fn parse_watch_entry(ent: &toml::Value) -> Result<SpoolDir, Error> { | |||||
use toml::Value::*; | |||||
let sender = ent.get("sender"); | |||||
let path = ent.get("path"); | |||||
let dest = ent.get("destroom"); | |||||
let delay = ent.get("delay"); | |||||
// Again this is gross, but whatever. | |||||
match (path, sender, dest) { | |||||
(Some(String(p)), Some(String(s)), Some(String(d))) => Ok(SpoolDir { | |||||
path: PathBuf::from(p.clone()), | |||||
send_delay_sec: delay | |||||
.cloned() | |||||
.map(|v| v.try_into::<u32>()) | |||||
.transpose()? | |||||
.unwrap_or(0), | |||||
sender_acct_label: s.clone(), | |||||
dest_room_id: RoomId::try_from(d.as_str()).map_err(|_| Error::ParseIdentifier)?, | |||||
}), | |||||
_ => Err(Error::ParseMalformed), | |||||
} | |||||
} |
//#![allow(unused)] | |||||
#![allow(incomplete_features)] | |||||
#![feature(impl_trait_in_bindings)] | |||||
#![feature(async_closure)] | |||||
mod config; | |||||
mod spool; | |||||
use std::path::PathBuf; | |||||
use clap::Clap; | |||||
use tokio::{self, runtime}; | |||||
use crate::config::*; | |||||
#[derive(Clap)] | |||||
#[clap(version = "0.1")] | |||||
struct Opts { | |||||
#[clap( | |||||
name = "config", | |||||
short = "c", | |||||
help = "Read this config file by itself, parsed before -C" | |||||
)] | |||||
conf: Option<PathBuf>, | |||||
#[clap( | |||||
name = "configdir", | |||||
short = "C", | |||||
help = "Read all config files in this directory" | |||||
)] | |||||
conf_dir: Option<PathBuf>, | |||||
#[clap( | |||||
name = "triggerpath", | |||||
short = "r", | |||||
help = "Delete this file to trigger a config reload [NYI]" | |||||
)] | |||||
reload_trigger: Option<PathBuf>, | |||||
} | |||||
fn main() { | |||||
let opts = Opts::parse(); | |||||
if opts.reload_trigger.is_some() { | |||||
println!("Reload trigger file specified, but this option is not supported yet. Ignoring."); | |||||
} | |||||
let mut rt = make_runtime(); | |||||
// Figure out which files we want to configure. | |||||
let mut confs = Vec::new(); | |||||
if let Some(main) = opts.conf { | |||||
confs.push(main.clone()); | |||||
} | |||||
if let Some(dir) = opts.conf_dir { | |||||
match rt.block_on(find_configs(&dir)) { | |||||
Ok(paths) => confs.extend(paths), | |||||
Err(e) => { | |||||
println!("Error reading configuration: {:?}", e); | |||||
return; | |||||
} | |||||
} | |||||
} | |||||
// Sanity check. | |||||
if confs.len() == 0 { | |||||
println!("No configuration declared, exiting..."); | |||||
return; | |||||
} | |||||
// Process configuration. | |||||
let config = match rt.block_on(parse_configs(confs)) { | |||||
Ok(c) => c, | |||||
Err(e) => { | |||||
println!("Error parsing configuration: {:?}", e); | |||||
return; // maybe | |||||
} | |||||
}; | |||||
for s in &config.spool_dirs { | |||||
if s.send_delay_sec != 0 { | |||||
println!( | |||||
"Warning: send delay (as in watch on {:?}) are current not supported, ignoring", | |||||
s.path | |||||
); | |||||
} | |||||
} | |||||
// This is where the real stuff actually happens. | |||||
match rt.block_on(spool::start_spooling(config)) { | |||||
Ok(_) => {} | |||||
Err(e) => println!("fatal error: {:?}", e), | |||||
} | |||||
} | |||||
fn make_runtime() -> runtime::Runtime { | |||||
runtime::Builder::new() | |||||
.max_threads(1) | |||||
.enable_all() | |||||
.basic_scheduler() | |||||
.build() | |||||
.expect("init runtime") | |||||
} |
use std::collections::*; | |||||
use std::path::PathBuf; | |||||
use std::sync::Arc; | |||||
use futures::compat::Stream01CompatExt; | |||||
use futures::prelude::*; | |||||
use hyper::client::connect::dns::GaiResolver; | |||||
use hyper::client::connect::HttpConnector; | |||||
use hyper_tls::HttpsConnector; | |||||
use inotify::ffi::*; | |||||
use tokio::fs as tokiofs; | |||||
use tokio::prelude::*; | |||||
use tokio_inotify; | |||||
use url::Url; | |||||
use ruma_client::Client; | |||||
use ruma_client_api::r0::message as rumamessage; | |||||
use ruma_events::{self, room::message::*}; | |||||
use ruma_identifiers::RoomId; | |||||
use crate::config::*; | |||||
type MatrixClient = Client<HttpsConnector<HttpConnector<GaiResolver>>>; | |||||
type MessageRequest = rumamessage::create_message_event::Request; | |||||
#[derive(Debug)] | |||||
pub enum Error { | |||||
FileFormatMismatch, | |||||
BadUrl, | |||||
Io(io::Error), | |||||
MtxClient(ruma_client::Error), | |||||
} | |||||
impl From<io::Error> for Error { | |||||
fn from(f: io::Error) -> Self { | |||||
Self::Io(f) | |||||
} | |||||
} | |||||
impl From<ruma_client::Error> for Error { | |||||
fn from(f: ruma_client::Error) -> Self { | |||||
Self::MtxClient(f) | |||||
} | |||||
} | |||||
#[derive(Clone)] | |||||
struct SpoolAction { | |||||
client: Arc<MatrixClient>, | |||||
room: RoomId, | |||||
delay_secs: u32, | |||||
watch_path: PathBuf, | |||||
} | |||||
pub async fn start_spooling(conf: Config) -> Result<(), Error> { | |||||
let ain = tokio_inotify::AsyncINotify::init().expect("inotify init"); | |||||
let mut clients = HashMap::new(); | |||||
let mut watch_map = HashMap::new(); | |||||
for s in conf.spool_dirs { | |||||
let label = s.sender_acct_label; | |||||
let w = ain.add_watch(&s.path, IN_CLOSE_WRITE | IN_MOVED_TO)?; | |||||
println!("Added watch: {}", s.path.to_str().unwrap_or("[non-UTF-8]")); | |||||
// TODO Make this better. | |||||
let cli = if !clients.contains_key(&label) { | |||||
let acc = conf.accounts.get(&label).expect("missing account"); | |||||
let cli = create_and_auth_client(acc.clone()).await?; | |||||
clients.insert(label, cli.clone()); | |||||
cli | |||||
} else { | |||||
clients.get(&label).expect("missing account").clone() | |||||
}; | |||||
let sa = SpoolAction { | |||||
client: cli, | |||||
room: s.dest_room_id, | |||||
delay_secs: s.send_delay_sec, | |||||
watch_path: s.path.clone(), | |||||
}; | |||||
watch_map.insert(w, sa); | |||||
} | |||||
let watch_map = Arc::new(watch_map); | |||||
// This is horrible. | |||||
let _ = ain | |||||
.compat() | |||||
.map_err(Error::from) | |||||
.try_for_each({ | |||||
|e| { | |||||
// Not sure why we have to do this clone here outside. | |||||
let wm = watch_map.clone(); | |||||
async move { | |||||
// I don't like these clones but idk any better way. | |||||
let act = match wm.as_ref().get(&e.wd) { | |||||
Some(a) => a.clone(), | |||||
None => { | |||||
println!( | |||||
"got a wd that was not from a watch we added, ignoring: {:?}", | |||||
e.wd | |||||
); | |||||
return Ok(()); | |||||
} | |||||
}; | |||||
//let pb = e.name.clone(); | |||||
// TODO This should be spawning a new task. | |||||
//tokio::spawn(async move { | |||||
// TODO Respect delay. | |||||
match process_file(&e.name, &act).await { | |||||
Ok(()) => {} // ok I guess? | |||||
Err(e) => println!("Error processing file: {:?}", e), | |||||
} | |||||
//}); | |||||
Ok(()) | |||||
} | |||||
} | |||||
}) | |||||
.await?; | |||||
Ok(()) | |||||
} | |||||
async fn process_file(p: &PathBuf, sa: &SpoolAction) -> Result<(), Error> { | |||||
let ext = match p.extension().map(|e| e.to_str()).flatten() { | |||||
Some(v) => v, | |||||
None => { | |||||
println!("Found weird file {:?}, ignoring", p); | |||||
return Ok(()); | |||||
} | |||||
}; | |||||
let name = p | |||||
.file_name() | |||||
.map(|e| e.to_str()) | |||||
.flatten() | |||||
.unwrap_or("[non-UTF-8]"); | |||||
// This makes me *mad*. | |||||
let mut real_path = sa.watch_path.clone(); | |||||
real_path.push(p); | |||||
match ext { | |||||
"txt" => { | |||||
println!("Processing file for {} at {:?}", sa.room, p); | |||||
let buf = match file_as_string(&real_path).await { | |||||
Ok(v) => v, | |||||
Err(Error::FileFormatMismatch) => { | |||||
println!("File {} is not UTF-8, ignoring", name); | |||||
return Ok(()); | |||||
} | |||||
Err(e) => return Err(e), | |||||
}; | |||||
let mut rng = rand::thread_rng(); | |||||
let req = make_text_request(sa.room.clone(), buf.as_str(), &mut rng); | |||||
match sa.client.as_ref().request(req).await { | |||||
Ok(_) => { | |||||
// Now delete it if it passed. | |||||
tokiofs::remove_file(real_path).await?; | |||||
} | |||||
Err(e) => println!("Error processing {}: {:?}", name, e), | |||||
} | |||||
} | |||||
_ => println!( | |||||
"Found file {:?}, but it has unsupported extension \"{}\"", | |||||
p, ext | |||||
), | |||||
} | |||||
Ok(()) | |||||
} | |||||
async fn file_as_string(p: &PathBuf) -> Result<String, Error> { | |||||
let mut buf = Vec::new(); | |||||
let mut f = tokiofs::File::open(p).await?; | |||||
f.read_to_end(&mut buf).await?; | |||||
String::from_utf8(buf).map_err(|_| Error::FileFormatMismatch) | |||||
} | |||||
async fn create_and_auth_client(acct: Account) -> Result<Arc<MatrixClient>, Error> { | |||||
let hs_url = Url::parse(&acct.homeserver).map_err(|_| Error::BadUrl)?; | |||||
let c = MatrixClient::https(hs_url, None); | |||||
match acct.auth { | |||||
Auth::UsernamePass(un, pw) => c.log_in(un, pw, acct.device_id, acct.display).await?, | |||||
}; | |||||
Ok(Arc::new(c)) | |||||
} | |||||
fn make_text_request<R: rand::Rng>(room_id: RoomId, msg: &str, rng: &mut R) -> MessageRequest { | |||||
let inner = TextMessageEventContent { | |||||
body: String::from(msg), | |||||
format: None, | |||||
formatted_body: None, | |||||
relates_to: None, | |||||
}; | |||||
let mec = MessageEventContent::Text(inner); | |||||
MessageRequest { | |||||
room_id: room_id, | |||||
event_type: ruma_events::EventType::RoomMessage, | |||||
txn_id: make_txn_id(rng), | |||||
data: mec, | |||||
} | |||||
} | |||||
const TXN_ID_LEN: usize = 20; | |||||
fn make_txn_id<R: rand::Rng>(rng: &mut R) -> String { | |||||
let mut buf = String::with_capacity(TXN_ID_LEN); | |||||
for _ in 0..TXN_ID_LEN { | |||||
buf.push((rng.gen_range(0, 26) + ('a' as u8)) as char); | |||||
} | |||||
buf | |||||
} |