From 3d6a006f19dc48d8b91f0857cb92ac72a5ed20a5 Mon Sep 17 00:00:00 2001 From: Thelie Date: Sun, 23 May 2021 22:47:43 +0200 Subject: [PATCH] Version 0.2.0 The bot can now send emails! Reviewed-on: http://gitea.chaostreff-alzey.de/Thelie/Mention2Mail/pulls/2 Co-authored-by: Thelie Co-committed-by: Thelie --- .gitignore | 1 + Cargo.lock | 8 +- Cargo.toml | 2 +- src/config.rs | 121 ++++++++++++++++++++++ src/main.rs | 275 ++++++++++++++----------------------------------- src/servers.rs | 219 +++++++++++++++++++++++++++++++++++++++ src/texts.rs | 21 ++++ 7 files changed, 446 insertions(+), 201 deletions(-) create mode 100644 src/config.rs create mode 100644 src/servers.rs create mode 100644 src/texts.rs diff --git a/.gitignore b/.gitignore index f044ebe..596273a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ target/ # Dont track the client config config.toml +config/ diff --git a/Cargo.lock b/Cargo.lock index bffb171..d135573 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -309,9 +309,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "lettre" -version = "0.9.5" +version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "338d9a248c4b3ef60c51941c678bb8f64e244c0a98f1eb71db027d1e777a5700" +checksum = "86ed8677138975b573ab4949c35613931a4addeadd0a8a6aa0327e2a979660de" dependencies = [ "base64", "bufstream", @@ -583,9 +583,9 @@ checksum = "558dc50e1a5a5fa7112ca2ce4effcb321b0300c0d4ccf0776a9f60cd89031171" [[package]] name = "serde_derive" -version = "1.0.125" +version = "1.0.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b093b7a2bb58203b5da3056c05b4ec1fed827dcfdb37347a8841695263b3d06d" +checksum = "963a7dbc9895aeac7ac90e74f34a5d5261828f79df35cbed41e10189d3804d43" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 534b3cf..74a8f5d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mention_2_mail" -version = "0.1.0" +version = "0.2.0" authors = ["Thelie "] edition = "2018" diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..187fb9f --- /dev/null +++ b/src/config.rs @@ -0,0 +1,121 @@ +/* Copyright 2021 Daniel Mowitz + * This file is part of Mention2Mail. + * + * Mention2Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Mention2Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Mention2Mail. If not, see . + */ + +use std::{ + path::PathBuf, + io::Read, + fs::read_dir, +}; +use toml::{ + value::Value, + map::Map, +}; + +fn parse_toml_to_table(path: PathBuf) + -> Result, Box> { + // I once read that this multiple let style + // is the "rusty" way to do things. + // Personally, I find this specific instance to be somewhat sketchy. + let mut config = String::new(); + std::fs::File::open(path)? + .read_to_string(&mut config)?; + let mut config = config.parse::()?; + // The important thing here is that config is a mut Table in the end. + Ok(config.as_table_mut().unwrap().clone()) +} + +fn merge_config_maps(mut receiver: Map, donor: Map) + -> Result, Box> { + for key in donor.keys() { + if receiver.contains_key(&key.clone()) { + receiver[&key.clone()] = donor[key].clone(); + } else { + receiver.insert(key.clone(), donor[key].clone()); + } + } + Ok(receiver) +} + +fn parse_server_dir(mut config: Value, server_config_path: PathBuf) + -> Result> { + let mut config = config.as_table_mut().unwrap().clone(); + if server_config_path.is_dir() { + for entry in read_dir(server_config_path)? { + let entry = entry?; + match entry.file_name().to_str().ok_or("Could not read file name")? { + "config.toml" => { + let server_config = parse_toml_to_table(entry.path())?; + config = merge_config_maps(config, server_config)?; + }, + "clients.toml" => { + let client_config = parse_toml_to_table(entry.path())?; + config = merge_config_maps(config, client_config)?; + }, + "channels.toml" => { + let channels = Value::from(parse_toml_to_table(entry.path())?); + if config.contains_key(&"channels".to_owned()) { + config[&"channels".to_owned()] = channels; + } else { + config.insert("channels".to_owned(), channels); + } + }, + _ => (), + } + } + } + + Ok(Value::from(config)) +} + +/// parses a toml config file and sets some default values +/// if the corresponding fields are not set. +// TODO: Document config file options once they are stable. +pub fn get_main_config(config_path: PathBuf) + -> Result> { + let mut config = parse_toml_to_table(config_path)?; + + if config.get("port") == None { + config.insert(String::from("port"), Value::from("6697")); + } + + if config.get("tls") == None { + if config["port"].as_integer() == Some(194) || + config["port"].as_integer() == Some(6667) { + config.insert(String::from("tls"), Value::from(false)); + }else if config["port"].as_integer() == Some(6696) || + config["port"].as_integer() == Some(6697) { + config.insert(String::from("tls"), Value::from(true)); + } + } + + Ok(Value::from(config)) +} + +pub fn get_server_configs(config: Value, config_path: PathBuf) + -> Result, Box> { + let mut config_vec = vec![]; + if config_path.is_dir() { + for entry in read_dir(config_path)? { + let entry = entry?; + let server_path = entry.path(); + if server_path.is_dir(){ + config_vec.push(parse_server_dir(config.clone(), server_path)?); + } + } + } + Ok(config_vec) +} diff --git a/src/main.rs b/src/main.rs index 6b7f18e..729b0d8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,214 +16,97 @@ */ pub mod connect; +pub mod config; +pub mod servers; +pub mod texts; use std::{ - format, env::args, - path::Path, - io::Read, - collections::VecDeque, + path::PathBuf, + sync::mpsc::channel, + thread, }; -use irc_proto::command::{ - CapSubCommand, - Command, +use lettre::{ + SendmailTransport, + Transport }; -use irc_proto::message::Message; -use irc_stream::{ - IrcRead, - IrcWrite, -}; -use toml::value::Value; - -trait IrcReadWrite: IrcRead + IrcWrite {} -impl IrcReadWrite for T {} - -/// parses a toml config file and sets some default values -/// if the corresponding fields are not set. -// TODO: Document config file options once they are stable. -fn get_config>(config_path: P) - -> Result> { - - // I once read that this multiple let style - // is the "rusty" way to do things. - // Personally, I find this specific instance to be somewhat sketchy. - let mut config = String::new(); - std::fs::File::open(config_path)? - .read_to_string(&mut config)?; - let mut config = config.parse::()?; - // The important thing here is that config is a mut Table in the end. - let mut config = config.as_table_mut().unwrap().clone(); - - if config.get("port") == None { - config.insert(String::from("port"), Value::from("6697")); - } - - if config.get("tls") == None { - if config["port"].as_integer() == Some(194) || - config["port"].as_integer() == Some(6667) { - config.insert(String::from("tls"), Value::from(false)); - }else if config["port"].as_integer() == Some(6696) || - config["port"].as_integer() == Some(6697) { - config.insert(String::from("tls"), Value::from(true)); - } - } - - Ok(Value::from(config)) -} - -/// Constructs a VecDeque with the messages -/// required to identify with an IRC server. -/// Uses the credentials set in -`config.toml`. -fn get_irc_identify_messages (config: &Value) - -> Result, Box> { - let mut queue = VecDeque::new(); - - queue.push_back(Message::from( - Command::CAP( - None, - CapSubCommand::END, - None, - None - ))); - - match config.get("password") { - Some(p) => queue.push_back(Message::from( - Command::PASS( - String::from(p.as_str().ok_or("Could not parse password.")?) - ) - )), - None => () - } - - let nick: String; - match config.get("nickname") { - Some(n) => { - nick = String::from(n.as_str().ok_or("Could not parse nickname.")?); - queue.push_back(Message::from(Command::NICK(nick.clone()))); - }, - None => return Err("No nickname supplied!".into()), - } - - match config.get("username") { - Some(u) => queue.push_back(Message::from( - Command::USER( - String::from(u.as_str().ok_or("Could not parse username.")?), - "0".to_owned(), - String::from(u.as_str().ok_or("Could not parse username.")?) - ) - )), - None => queue.push_back(Message::from( - // nick.clone() is only used once because the value - // can be moved the second time - Command::USER(nick.clone(), "0".to_owned(), nick) - )) - } - Ok(queue) -} - -/// Appends a given VecDeque to include the messages -/// used to join the channels defined in `config.toml`. -fn get_irc_join_messages(config: &Value, queue: VecDeque) - -> Result, Box> { - let mut queue = queue; - - match config.get("channels") { - Some(c) => { - for channel in c.as_array().ok_or("Could not parse channels.")? { - queue.push_back(Message::from( - Command::JOIN( - String::from(channel.as_str().ok_or("Could not parse one of the channels")?), - None, - None - ) - )) - } - }, - None => () - } - - Ok(queue) -} - -fn handle_message(message: Message) -> Option { - let sender = match message.clone().source_nickname() { - Some(s) => String::from(s), - None => String::from("anonymous") - }; - match message.clone().command { - Command::PING(ref data, _) => { - return Some(Message::from( - Command::PONG(data.to_owned(), None) - )); - }, - Command::PRIVMSG(ref rec, ref msg) => println!( - "{} -> {}: {}", - rec, - msg, - sender - ), - _ => println!("{}", message.clone().to_string()) - } - return None -} fn main() { - let config_path = args().nth(1) - .expect("no config given"); - let config = get_config(config_path).expect("Could not get config"); - - let mut stream = connect::connect_irc(&config).unwrap(); - - let mut message_queue = get_irc_identify_messages(&config).unwrap(); - - while let Some(message) = message_queue.pop_front() { - stream.write(message).unwrap(); - } - - message_queue = get_irc_join_messages(&config, message_queue).unwrap(); - - // Wait for first ping and join channels after sending pong. - // TODO this approach is not very DRY! - loop { - let message = match stream.read() { - Ok(m) => m, - Err(e) => Message::from( - Command::PRIVMSG( - String::from("Error"), - format!("{}", e) - ) - ) - }; - match message.command { - Command::PING(ref data, _) => { - stream.write(Message::from( - Command::PONG(data.to_owned(), None) - )).unwrap(); - while let Some(message) = message_queue.pop_front() { - stream.write(message).unwrap(); - } - break; - } - _ => match handle_message(message) { - Some(m) => stream.write(m).unwrap(), - None => () + let mut server_conf_flag = false; + let mut config_path = None; + let mut server_conf_path = None; + for arg in args().skip(1) { + match arg.as_str() { + "-s" => server_conf_flag = true, + "-h" => { + println!("Usage: mention2mail [config file] [-s server config directory]"); + }, + _ => if server_conf_flag { + server_conf_path = Some(arg); + } else { + config_path = Some(arg); } } } + let config; + + match config_path { + Some(p) => config = config::get_main_config(PathBuf::from(p)) + .expect("Could not get config"), + None => config = config::get_main_config(PathBuf::from("/etc/Mention2Mail/config.toml")) + .expect("Could not get default config in /etc/Mention2Mail/default.toml"), + } + + let server_configs; + + match server_conf_path { + Some(p) => server_configs = config::get_server_configs(config, PathBuf::from(p)) + .expect("Could not get server config."), + None => server_configs = vec![config], + } + + let (tx,rx) = channel(); + let mut server_threads = vec![]; + + for s_conf in server_configs { + let t = tx.clone(); + server_threads.push(thread::Builder::new() + .name("server name here".to_string()) + .spawn(move || { + servers::handle_server(s_conf, t).unwrap(); + }) + ); + } + + let mut mailer = SendmailTransport::new(); + loop { - let message = match stream.read() { - Ok(m) => m, - Err(e) => Message::from( - Command::PRIVMSG( - String::from("Error"), - format!("{}", e) - ) - ) - }; - match handle_message(message) { - Some(m) => stream.write(m).unwrap(), - None => () + match rx.recv() { + Ok(data) => { + println!("Sending mail to: {}", data[0]); + let id = "notarealiid@mention2mail".to_owned(); + let body = format!("Subject: You were mentioned in {} on {}\n{} wrote: {}", + data[2], data[1], data[3], data[4]).into_bytes(); + let mail = lettre::SendableEmail::new( + lettre::Envelope::new( + Some( + lettre::EmailAddress::new("m2m@chaostreff-alzey.de".to_owned()) + .unwrap() + ), + vec![ + lettre::EmailAddress::new(data[0].clone()).unwrap()] + ).unwrap(), + id, + body + ); + + match mailer.send(mail) { + Ok(_) => println!("Email sent successfully!"), + Err(e) => panic!("Could not send email: {:?}", e), + } + } + Err(_e) => (), } } } diff --git a/src/servers.rs b/src/servers.rs new file mode 100644 index 0000000..47fe91d --- /dev/null +++ b/src/servers.rs @@ -0,0 +1,219 @@ +/* Copyright 2021 Daniel Mowitz + * This file is part of Mention2Mail. + * + * Mention2Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Mention2Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Mention2Mail. If not, see . + */ + +use std::{ + collections::VecDeque, + sync::mpsc::Sender, +}; +use irc_proto::command::{ + CapSubCommand, + Command, +}; +use toml::map::{ + Map, + Keys +}; +use irc_proto::message::Message; +use toml::value::Value; + +/// Constructs a VecDeque with the messages +/// required to identify with an IRC server. +/// Uses the credentials set in -`config.toml`. +fn get_irc_identify_messages (config: &Value) + -> Result, Box> { + let mut queue = VecDeque::new(); + + queue.push_back(Message::from( + Command::CAP( + None, + CapSubCommand::END, + None, + None + ))); + + match config.get("password") { + Some(p) => queue.push_back(Message::from( + Command::PASS( + String::from(p.as_str().ok_or("Could not parse password.")?) + ) + )), + None => () + } + + let nick: String; + match config.get("nickname") { + Some(n) => { + nick = String::from(n.as_str().ok_or("Could not parse nickname.")?); + queue.push_back(Message::from(Command::NICK(nick.clone()))); + }, + None => return Err("No nickname supplied!".into()), + } + + match config.get("username") { + Some(u) => queue.push_back(Message::from( + Command::USER( + String::from(u.as_str().ok_or("Could not parse username.")?), + "0".to_owned(), + String::from(u.as_str().ok_or("Could not parse username.")?) + ) + )), + None => queue.push_back(Message::from( + // nick.clone() is only used once because the value + // can be moved the second time + Command::USER(nick.clone(), "0".to_owned(), nick) + )) + } + Ok(queue) +} + +/// Appends a given VecDeque to include the messages +/// used to join the channels defined in `config.toml`. +fn get_irc_join_messages(config: &Value, queue: VecDeque) + -> Result, Box> { + let mut queue = queue; + + match config.get("channels") { + Some(c) => { + for channel in c.as_array().ok_or("Could not parse channels.")? { + queue.push_back(Message::from( + Command::JOIN( + String::from(channel.as_str().ok_or("Could not parse one of the channels")?), + None, + None + ) + )) + } + }, + None => () + } + + Ok(queue) +} + +fn handle_message(message: Message, nick: &String, client_names: Keys) + -> (Option, Option<[String; 4]>) { + let sender = match message.clone().source_nickname() { + Some(s) => String::from(s), + None => String::from("anonymous") + }; + match message.clone().command { + Command::PING(ref data, _) => { + return ( + Some(Message::from( + Command::PONG(data.to_owned(), None) + )), + None + ); + }, + Command::PRIVMSG(ref rec, ref msg) => { + // Send bot info on receiving a private message. + if rec.as_str() == nick.as_str() { + return ( + Some(Message::from( + Command::PRIVMSG( + sender.clone(), + super::texts::get_bot_info(), + ) + ) + ), + None + ) + } + for c_name in client_names { + if msg.contains(c_name) { + return (None, Some([c_name.clone(), + rec.clone(), + sender.clone(), + msg.clone() + ])) + } + } + }, + _ => println!("{}", message.clone().to_string()) + } + return (None, None) +} + +pub fn handle_server(config: Value, tx: Sender<[String; 5]>) + -> Result<(), Box> { + let server_name = config.get("server").unwrap().as_str() + .ok_or("Could not get server adress from config")?; + let nick = config.get("nickname").unwrap().as_str() + .ok_or("Could not get nickname from config")?.to_owned(); + + let clients; + match config.get("clients").unwrap().as_table() { + Some(t) => clients = t.clone(), + None => clients = Map::new(), + } + + let mut stream = super::connect::connect_irc(&config).unwrap(); + let mut message_queue = get_irc_identify_messages(&config).unwrap(); + + while let Some(message) = message_queue.pop_front() { + stream.write(message).unwrap(); + } + + message_queue = get_irc_join_messages(&config, message_queue).unwrap(); + + // Wait for first ping and join channels after sending pong. + loop { + let message = stream.read()?; + match message.command { + Command::PING(ref data, _) => { + stream.write(Message::from( + Command::PONG(data.to_owned(), None) + )).unwrap(); + while let Some(message) = message_queue.pop_front() { + stream.write(message).unwrap(); + } + break; + } + _ => () + } + } + + // Handle all incoming messages after joining the channels. + loop { + let message = stream.read()?; + let (answer, data) = handle_message(message, &nick, clients.keys()); + match answer { + Some(a) => stream.write(a).unwrap(), + None => () + } + match data { + Some(d) => match clients.get(&d[0]) { + Some(addr) => { + // There must be a better way to do this… + let out = [ + addr.as_str() + .ok_or("Could not parse email address.")? + .to_owned(), + server_name.to_owned(), + d[1].clone(), + d[2].clone(), + d[3].clone(), + ]; + tx.send(out)? + }, + None => (), + }, + None => (), + } + } +} + diff --git a/src/texts.rs b/src/texts.rs new file mode 100644 index 0000000..782a63c --- /dev/null +++ b/src/texts.rs @@ -0,0 +1,21 @@ +/* Copyright 2021 Daniel Mowitz + * This file is part of Mention2Mail. + * + * Mention2Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Mention2Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Mention2Mail. If not, see . + */ + +pub fn get_bot_info() -> String{ +"Hello! I'm an instance of Mention2Mail. I am licensed under the AGPLv3. You can find my source code at: https://gitea.chaostreff-alzey.de/Thelie/Mention2Mail/".to_owned() +} +