1
0
Fork 0

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
Dustin 2021-08-22 20:42:30 -05:00
parent 53bc0644a6
commit a58a04c350
5 changed files with 2131 additions and 136 deletions

1905
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -15,6 +15,15 @@ serde_json = "1.0"
version = "0.4"
features = ["serde"]
[dependencies.matrix-sdk]
version = "^0.3.0"
default-features = false
features = ["markdown", "native-tls"]
[dependencies.serde]
version = "1.0"
features = ["derive"]
[dependencies.tokio]
version = "^1.10.0"
features = ["rt", "macros"]

View File

@ -1,12 +1,14 @@
use chrono::Duration;
use chrono;
use chrono::Duration;
use serde::{Deserialize, Serialize};
use serde_json;
use std::collections::HashMap;
use std::env;
use std::fmt;
use std::fs;
use std::io;
use std::io::prelude::*;
use std::path::PathBuf;
#[derive(Serialize, Deserialize, Debug)]
pub struct CacheEntry {
@ -32,6 +34,7 @@ impl CacheEntry {
#[derive(Serialize, Deserialize, Debug)]
pub struct Cache {
paths: HashMap<String, CacheEntry>,
device_id: Option<String>,
}
#[derive(Debug)]
@ -71,6 +74,7 @@ impl Cache {
pub fn new() -> Self {
Self {
paths: HashMap::new(),
device_id: None,
}
}
@ -104,9 +108,41 @@ impl Cache {
}
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 contents = serde_json::to_string(&self)?;
file.write_all(&contents.as_bytes())?;
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
}

95
src/config.rs Normal file
View File

@ -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
}

View File

@ -1,29 +1,27 @@
use chrono::Duration;
use clap::App;
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::io::prelude::*;
use std::num::ParseIntError;
use std::path::PathBuf;
use std::process;
use tokio;
mod cache;
mod config;
mod error;
mod mountinfo;
use mountinfo::get_fs_uuid;
const CACHE_FILENAME: &'static str = "mizule.json";
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() {
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), matrix_sdk::Error> {
let matches = App::new("Mizule")
.version(env!("CARGO_PKG_VERSION"))
.author(env!("CARGO_PKG_AUTHORS"))
@ -33,93 +31,115 @@ fn main() {
.help("Path to the mounted filesystem to check")
.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();
let mountpoint = matches.value_of("mountpoint").unwrap();
let ttl: i64 = matches.value_of("ttl").unwrap().parse().unwrap();
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);
}
let config = match config::Config::load(None) {
Ok(c) => c,
Err(e) => {
eprintln!("Error getting filesystem UUID: {}", e);
eprintln!("Error loading configuration: {}", e);
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);
match cache {
Ok(mut cache) => {
if let Some(entry) = cache.get(&mountpoint) {
if entry.uuid() != uuid {
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);
}
}
}
let mut cache_path = cache::get_cache_dir();
cache_path.push("cache.json");
let cache_path = cache_path.to_str().unwrap();
let mut cache = match cache::Cache::load(cache_path) {
Ok(c) => c,
Err(e) => {
eprintln!("Error loading cache: {}", e);
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);
}
}
fn notify(
Ok(())
}
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,
uuid: &str,
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 delta = now - changed;
let message = format!(
concat!(
"The filesystem mounted at {} (UUID {}) ",
"The filesystem mounted at `{}` (UUID {}) ",
"was last changed on {} ({} days ago)",
),
mountpoint,
@ -128,37 +148,13 @@ fn notify(
delta.num_days()
);
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(
mailto: &str,
subject: &str,
message: &str,
) -> Result<(), std::io::Error> {
let mut cmd = process::Command::new("sendmail")
.arg("-t")
.stdin(process::Stdio::piped())
.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(())
let content =
AnyMessageEventContent::RoomMessage(MessageEventContent::new(
MessageType::Text(TextMessageEventContent::markdown(message)),
));
room.send(content, None)
.await
.expect("Failed to send Matrix notification");
return cache_changed;
}