@@ -0,0 +1,2 @@ | |||
/target | |||
*~ |
@@ -0,0 +1,22 @@ | |||
[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" |
@@ -0,0 +1,211 @@ | |||
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), | |||
} | |||
} |
@@ -0,0 +1,103 @@ | |||
//#![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") | |||
} |
@@ -0,0 +1,224 @@ | |||
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 | |||
} |