Send notifications with Matrix
I don't pay enough attention to e-mail notifications any more, since I no longer get alerts for them on my phone. I do get Matrix notifications, though, through Element, and that seems to work well as a reminder for me. Matrix is of course a lot more complex than e-mail. It is a stateful protocol that requires (at least) keeping authentication and/or session information on the client. Technically, clients all have a "device ID," which they should use any time they communicate with the server. I decided it makes the most sense to keep this value in the same cache file as the filesystem UUIDs and timestamps. I prefer reading usernames and passwords from a configuration file over passing them as command-line arguments, so I added that ability as well.master
parent
53bc0644a6
commit
a58a04c350
File diff suppressed because it is too large
Load Diff
|
@ -15,6 +15,15 @@ serde_json = "1.0"
|
||||||
version = "0.4"
|
version = "0.4"
|
||||||
features = ["serde"]
|
features = ["serde"]
|
||||||
|
|
||||||
|
[dependencies.matrix-sdk]
|
||||||
|
version = "^0.3.0"
|
||||||
|
default-features = false
|
||||||
|
features = ["markdown", "native-tls"]
|
||||||
|
|
||||||
[dependencies.serde]
|
[dependencies.serde]
|
||||||
version = "1.0"
|
version = "1.0"
|
||||||
features = ["derive"]
|
features = ["derive"]
|
||||||
|
|
||||||
|
[dependencies.tokio]
|
||||||
|
version = "^1.10.0"
|
||||||
|
features = ["rt", "macros"]
|
||||||
|
|
38
src/cache.rs
38
src/cache.rs
|
@ -1,12 +1,14 @@
|
||||||
use chrono::Duration;
|
|
||||||
use chrono;
|
use chrono;
|
||||||
|
use chrono::Duration;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json;
|
use serde_json;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::env;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::io::prelude::*;
|
use std::io::prelude::*;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
pub struct CacheEntry {
|
pub struct CacheEntry {
|
||||||
|
@ -32,6 +34,7 @@ impl CacheEntry {
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
pub struct Cache {
|
pub struct Cache {
|
||||||
paths: HashMap<String, CacheEntry>,
|
paths: HashMap<String, CacheEntry>,
|
||||||
|
device_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
@ -71,6 +74,7 @@ impl Cache {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
paths: HashMap::new(),
|
paths: HashMap::new(),
|
||||||
|
device_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -104,9 +108,41 @@ impl Cache {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save(&self, path: &str) -> Result<(), Error> {
|
pub fn save(&self, path: &str) -> Result<(), Error> {
|
||||||
|
let path = PathBuf::from(path);
|
||||||
|
match path.parent() {
|
||||||
|
Some(p) => {
|
||||||
|
if !p.is_dir() {
|
||||||
|
fs::create_dir_all(p)?;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {},
|
||||||
|
};
|
||||||
let mut file = fs::File::create(path)?;
|
let mut file = fs::File::create(path)?;
|
||||||
let contents = serde_json::to_string(&self)?;
|
let contents = serde_json::to_string(&self)?;
|
||||||
file.write_all(&contents.as_bytes())?;
|
file.write_all(&contents.as_bytes())?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn device_id(&self) -> Option<&str> {
|
||||||
|
match &self.device_id {
|
||||||
|
Some(p) => Some(p),
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_device_id(&mut self, device_id: &str) {
|
||||||
|
self.device_id = Some(device_id.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_cache_dir() -> PathBuf {
|
||||||
|
let mut dir = match env::var("XDG_CACHE_HOME") {
|
||||||
|
Ok(v) => PathBuf::from(&v),
|
||||||
|
Err(_) => match env::var("HOME") {
|
||||||
|
Ok(v) => [v, ".cache".into()].iter().collect(),
|
||||||
|
Err(_) => ".".into(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
dir.push(env!("CARGO_PKG_NAME"));
|
||||||
|
dir
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,95 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::env;
|
||||||
|
use std::fmt;
|
||||||
|
use std::fs;
|
||||||
|
use std::io;
|
||||||
|
use std::io::prelude::*;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ConfigError {
|
||||||
|
Io(io::Error),
|
||||||
|
Json(serde_json::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigError {
|
||||||
|
pub fn message(&self) -> String {
|
||||||
|
match *self {
|
||||||
|
Self::Io(ref e) => format!("{}", e),
|
||||||
|
Self::Json(ref e) => format!("{}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<io::Error> for ConfigError {
|
||||||
|
fn from(error: io::Error) -> Self {
|
||||||
|
Self::Io(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<serde_json::Error> for ConfigError {
|
||||||
|
fn from(error: serde_json::Error) -> Self {
|
||||||
|
Self::Json(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ConfigError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
f.write_str(&self.message())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct Config {
|
||||||
|
pub ttl: i64,
|
||||||
|
pub homeserver: String,
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
pub room: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
ttl: 30,
|
||||||
|
homeserver: "".into(),
|
||||||
|
username: "".into(),
|
||||||
|
password: "".into(),
|
||||||
|
room: "".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(path: Option<&str>) -> Result<Self, ConfigError> {
|
||||||
|
let path = match path {
|
||||||
|
Some(p) => PathBuf::from(p),
|
||||||
|
None => {
|
||||||
|
let mut p = get_config_dir();
|
||||||
|
p.push("config.json");
|
||||||
|
p
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Ok(mut file) = fs::File::open(path) {
|
||||||
|
let mut contents = String::new();
|
||||||
|
file.read_to_string(&mut contents)?;
|
||||||
|
let config = serde_json::from_str(&contents);
|
||||||
|
match config {
|
||||||
|
Ok(c) => Ok(c),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Ok(Self::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_config_dir() -> PathBuf {
|
||||||
|
let mut dir = match env::var("XDG_CONFIG_HOME") {
|
||||||
|
Ok(v) => PathBuf::from(&v),
|
||||||
|
Err(_) => match env::var("HOME") {
|
||||||
|
Ok(v) => [v, ".config".into()].iter().collect(),
|
||||||
|
Err(_) => ".".into(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
dir.push(env!("CARGO_PKG_NAME"));
|
||||||
|
dir
|
||||||
|
}
|
220
src/main.rs
220
src/main.rs
|
@ -1,29 +1,27 @@
|
||||||
use chrono::Duration;
|
use chrono::Duration;
|
||||||
use clap::App;
|
use clap::App;
|
||||||
use clap::Arg;
|
use clap::Arg;
|
||||||
|
use matrix_sdk::events::room::message::{
|
||||||
|
MessageEventContent, MessageType, TextMessageEventContent,
|
||||||
|
};
|
||||||
|
use matrix_sdk::events::AnyMessageEventContent;
|
||||||
|
use matrix_sdk::identifiers::RoomIdOrAliasId;
|
||||||
|
use matrix_sdk::reqwest::Url;
|
||||||
|
use matrix_sdk::{Client, ClientConfig, SyncSettings};
|
||||||
|
use std::convert::TryFrom;
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::io::prelude::*;
|
|
||||||
use std::num::ParseIntError;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::process;
|
use std::process;
|
||||||
|
use tokio;
|
||||||
|
|
||||||
mod cache;
|
mod cache;
|
||||||
|
mod config;
|
||||||
mod error;
|
mod error;
|
||||||
mod mountinfo;
|
mod mountinfo;
|
||||||
|
|
||||||
use mountinfo::get_fs_uuid;
|
use mountinfo::get_fs_uuid;
|
||||||
|
|
||||||
const CACHE_FILENAME: &'static str = "mizule.json";
|
#[tokio::main(flavor = "current_thread")]
|
||||||
|
async fn main() -> Result<(), matrix_sdk::Error> {
|
||||||
fn validate_int(v: String) -> Result<(), String> {
|
|
||||||
let i: Result<i64, ParseIntError> = v.parse();
|
|
||||||
match i {
|
|
||||||
Ok(_) => Ok(()),
|
|
||||||
Err(e) => Err(format!("Invalid number: {}", e)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let matches = App::new("Mizule")
|
let matches = App::new("Mizule")
|
||||||
.version(env!("CARGO_PKG_VERSION"))
|
.version(env!("CARGO_PKG_VERSION"))
|
||||||
.author(env!("CARGO_PKG_AUTHORS"))
|
.author(env!("CARGO_PKG_AUTHORS"))
|
||||||
|
@ -33,93 +31,115 @@ fn main() {
|
||||||
.help("Path to the mounted filesystem to check")
|
.help("Path to the mounted filesystem to check")
|
||||||
.required(true),
|
.required(true),
|
||||||
)
|
)
|
||||||
.arg(
|
|
||||||
Arg::with_name("ttl")
|
|
||||||
.short("t")
|
|
||||||
.long("ttl")
|
|
||||||
.takes_value(true)
|
|
||||||
.validator(validate_int)
|
|
||||||
.default_value("30")
|
|
||||||
.help("Number of days before warning"),
|
|
||||||
)
|
|
||||||
.arg(
|
|
||||||
Arg::with_name("mailto")
|
|
||||||
.short("m")
|
|
||||||
.long("mailto")
|
|
||||||
.alias("mail-to")
|
|
||||||
.takes_value(true)
|
|
||||||
.help("Send warning to this email address"),
|
|
||||||
)
|
|
||||||
.get_matches();
|
.get_matches();
|
||||||
|
|
||||||
let mountpoint = matches.value_of("mountpoint").unwrap();
|
let config = match config::Config::load(None) {
|
||||||
let ttl: i64 = matches.value_of("ttl").unwrap().parse().unwrap();
|
Ok(c) => c,
|
||||||
let ttl = Duration::days(ttl);
|
|
||||||
let mailto = matches.value_of("mailto");
|
|
||||||
match get_fs_uuid(mountpoint) {
|
|
||||||
Ok(uuid) => {
|
|
||||||
check_and_notify(mountpoint, &uuid, ttl, mailto);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Error getting filesystem UUID: {}", e);
|
eprintln!("Error loading configuration: {}", e);
|
||||||
process::exit(1);
|
process::exit(1);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn check_and_notify(
|
|
||||||
mountpoint: &str,
|
|
||||||
uuid: &str,
|
|
||||||
ttl: Duration,
|
|
||||||
mailto: Option<&str>,
|
|
||||||
) {
|
|
||||||
let mut cache_path = match env::var("XDG_CACHE_HOME") {
|
|
||||||
Ok(v) => PathBuf::from(&v),
|
|
||||||
Err(_) => match env::var("HOME") {
|
|
||||||
Ok(v) => [v, ".cache".into()].iter().collect(),
|
|
||||||
Err(_) => ".".into(),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
cache_path.push(CACHE_FILENAME);
|
|
||||||
let cache_path = cache_path.to_str().unwrap();
|
|
||||||
|
|
||||||
let cache = cache::Cache::load(cache_path);
|
let mut cache_path = cache::get_cache_dir();
|
||||||
match cache {
|
cache_path.push("cache.json");
|
||||||
Ok(mut cache) => {
|
let cache_path = cache_path.to_str().unwrap();
|
||||||
if let Some(entry) = cache.get(&mountpoint) {
|
let mut cache = match cache::Cache::load(cache_path) {
|
||||||
if entry.uuid() != uuid {
|
Ok(c) => c,
|
||||||
cache.update(&mountpoint, &uuid);
|
|
||||||
if let Err(e) = cache.save(&cache_path) {
|
|
||||||
eprintln!("Failed to save cache: {}", e);
|
|
||||||
}
|
|
||||||
} else if entry.expired(ttl) {
|
|
||||||
notify(mountpoint, uuid, entry.changed(), mailto);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
cache.update(&mountpoint, &uuid);
|
|
||||||
if let Err(e) = cache.save(&cache_path) {
|
|
||||||
eprintln!("Failed to save cache: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Error loading cache: {}", e);
|
eprintln!("Error loading cache: {}", e);
|
||||||
process::exit(1);
|
process::exit(1);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mountpoint = matches.value_of("mountpoint").unwrap();
|
||||||
|
let fsuuid = match get_fs_uuid(mountpoint) {
|
||||||
|
Ok(uuid) => uuid,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Error getting filesystem UUID: {}", e);
|
||||||
|
process::exit(1);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
if check_and_notify(mountpoint, &fsuuid, &config, &mut cache).await {
|
||||||
|
if let Err(e) = cache.save(&cache_path) {
|
||||||
|
eprintln!("Failed to save cache: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn notify(
|
async fn check_and_notify(
|
||||||
|
mountpoint: &str,
|
||||||
|
uuid: &str,
|
||||||
|
config: &config::Config,
|
||||||
|
cache: &mut cache::Cache,
|
||||||
|
) -> bool {
|
||||||
|
let mut changed = false;
|
||||||
|
let ttl = Duration::days(config.ttl);
|
||||||
|
if let Some(entry) = cache.get(&mountpoint) {
|
||||||
|
if entry.uuid() != uuid {
|
||||||
|
cache.update(&mountpoint, &uuid);
|
||||||
|
changed = true;
|
||||||
|
} else if entry.expired(ttl) {
|
||||||
|
changed =
|
||||||
|
notify(mountpoint, uuid, entry.changed(), config, cache).await;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cache.update(&mountpoint, &uuid);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn notify(
|
||||||
mountpoint: &str,
|
mountpoint: &str,
|
||||||
uuid: &str,
|
uuid: &str,
|
||||||
changed: chrono::DateTime<chrono::Utc>,
|
changed: chrono::DateTime<chrono::Utc>,
|
||||||
mailto: Option<&str>,
|
config: &config::Config,
|
||||||
) {
|
cache: &mut cache::Cache,
|
||||||
|
) -> bool {
|
||||||
|
let mut cache_changed = false;
|
||||||
|
|
||||||
|
let room_id = RoomIdOrAliasId::try_from(config.room.clone()).unwrap();
|
||||||
|
let server_name = room_id.server_name().to_owned();
|
||||||
|
let server_names = [server_name];
|
||||||
|
|
||||||
|
let clientconfig = ClientConfig::new().store_path(cache::get_cache_dir());
|
||||||
|
let client = Client::new_with_config(
|
||||||
|
Url::parse(&config.homeserver).expect("Invalid homeserver URL"),
|
||||||
|
clientconfig,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let res = client
|
||||||
|
.login(
|
||||||
|
&config.username,
|
||||||
|
&config.password,
|
||||||
|
cache.device_id(),
|
||||||
|
Some(env!("CARGO_PKG_NAME")),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("Login failed");
|
||||||
|
if cache.device_id().is_none() {
|
||||||
|
cache.set_device_id(res.device_id.as_str());
|
||||||
|
cache_changed = true;
|
||||||
|
}
|
||||||
|
let res = client
|
||||||
|
.join_room_by_id_or_alias(&room_id, &server_names)
|
||||||
|
.await
|
||||||
|
.expect("Failed to join room");
|
||||||
|
client
|
||||||
|
.sync_once(SyncSettings::default())
|
||||||
|
.await
|
||||||
|
.expect("Failed to sync with Matrix server");
|
||||||
|
let room = client.get_joined_room(&res.room_id).unwrap();
|
||||||
|
|
||||||
let now = chrono::Utc::now();
|
let now = chrono::Utc::now();
|
||||||
let delta = now - changed;
|
let delta = now - changed;
|
||||||
let message = format!(
|
let message = format!(
|
||||||
concat!(
|
concat!(
|
||||||
"The filesystem mounted at {} (UUID {}) ",
|
"The filesystem mounted at `{}` (UUID {}) ",
|
||||||
"was last changed on {} ({} days ago)",
|
"was last changed on {} ({} days ago)",
|
||||||
),
|
),
|
||||||
mountpoint,
|
mountpoint,
|
||||||
|
@ -128,37 +148,13 @@ fn notify(
|
||||||
delta.num_days()
|
delta.num_days()
|
||||||
);
|
);
|
||||||
println!("{}", message);
|
println!("{}", message);
|
||||||
if let Some(mailto) = mailto {
|
|
||||||
println!("Sending notification to {}", mailto);
|
|
||||||
let subject = format!(
|
|
||||||
"{}: {} needs to be changed!",
|
|
||||||
env!("CARGO_PKG_NAME"),
|
|
||||||
mountpoint
|
|
||||||
);
|
|
||||||
if let Err(e) = sendmail(mailto, &subject, &message) {
|
|
||||||
eprintln!("Failed to send email: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sendmail(
|
let content =
|
||||||
mailto: &str,
|
AnyMessageEventContent::RoomMessage(MessageEventContent::new(
|
||||||
subject: &str,
|
MessageType::Text(TextMessageEventContent::markdown(message)),
|
||||||
message: &str,
|
));
|
||||||
) -> Result<(), std::io::Error> {
|
room.send(content, None)
|
||||||
let mut cmd = process::Command::new("sendmail")
|
.await
|
||||||
.arg("-t")
|
.expect("Failed to send Matrix notification");
|
||||||
.stdin(process::Stdio::piped())
|
return cache_changed;
|
||||||
.spawn()?;
|
|
||||||
{
|
|
||||||
let mut stdin = cmd.stdin.take().unwrap();
|
|
||||||
stdin.write_all(format!("To: {}\n", mailto).as_bytes())?;
|
|
||||||
stdin.write_all(format!("Subject: {}\n", subject).as_bytes())?;
|
|
||||||
stdin.write_all("\n".as_bytes())?;
|
|
||||||
stdin.write_all(message.as_bytes())?;
|
|
||||||
stdin.write_all("\n".as_bytes())?;
|
|
||||||
stdin.flush()?;
|
|
||||||
}
|
|
||||||
cmd.wait()?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue