diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..b953ced --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "reminder-bot" +version = "0.1.0" +authors = ["Amanda Graven "] +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +matrix-sdk = { git = "https://github.com/matrix-org/matrix-rust-sdk", rev = "dbf8cf2" } +ms-converter = "1.4" +#serde = { version = "1.0", features = ["derive"] } +rpassword = "5.0" +serde_json = "1.0" +tokio = { version = "1.0", features = ["macros", "time", "rt-multi-thread"]} diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..11a295d --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,7 @@ +hard_tabs=true +max_width = 100 +comment_width = 80 +wrap_comments = true +imports_granularity = "Crate" +use_small_heuristics = "Max" +group_imports = "StdExternalCrate" diff --git a/session.sample.json b/session.sample.json new file mode 100644 index 0000000..5451b54 --- /dev/null +++ b/session.sample.json @@ -0,0 +1,5 @@ +{ + "access_token": "asdf1234", + "user_id": "@alice:matrix.org", + "device_id": "ABCDEFGHIJ" +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..3c31c99 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,196 @@ +use std::{convert::TryFrom, fs::File}; + +use matrix_sdk::{ + events::{ + reaction::{ReactionEventContent, Relation as ReactionRelation}, + room::{ + member::MemberEventContent, + message::{MessageEvent, MessageEventContent, MessageType}, + }, + AnyMessageEventContent, AnyStrippedStateEvent, AnySyncMessageEvent, AnySyncRoomEvent, + StrippedStateEvent, + }, + identifiers::{RoomId, UserId}, + Client, ClientConfig, LoopCtrl, Session, SyncSettings, +}; + +#[tokio::main] +async fn main() { + if let Some("login") = std::env::args().nth(1).as_deref() { + println!("Performing interactive login"); + let path = std::env::var("SESSION_FILE").unwrap_or_else(|_| String::from("session.json")); + let file = std::fs::File::create(&path).expect("Can't open session file"); + let session = match login().await { + Ok(session) => session, + Err(e) => { + eprintln!("Login failed: {}", e); + return; + } + }; + serde_json::to_writer_pretty(file, &session).unwrap(); + println!("Interactive login completed"); + return; + } + let client = match restore_login().await { + Ok(client) => client, + Err(e) => { + eprintln!("Login failed: {}", e); + return; + } + }; + sync(client).await +} + +async fn client(user_id: UserId) -> matrix_sdk::Result { + match std::env::var_os("STORAGE_DIR") { + Some(dir) => { + Client::new_from_user_id_with_config(user_id, ClientConfig::new().store_path(&dir)) + .await + } + None => Client::new_from_user_id(user_id).await, + } +} + +/// Log in interactively via the TTY. +async fn login() -> Result> { + println!("Enter username (e.g. @alice:matrix.org):"); + let mut user = String::new(); + std::io::stdin().read_line(&mut user)?; + let user = UserId::try_from(user.trim())?; + println!("Enter password:"); + let password = rpassword::read_password_from_tty(None)?; + let client = client(user.clone()).await?; + let response = client.login(user.as_str(), password.trim(), None, Some("Reminder bot")).await?; + let session = Session { + access_token: response.access_token, + user_id: response.user_id, + device_id: response.device_id, + }; + Ok(session) +} + +/// Restores a login from a session file. It gets the file path from the `SESSION_PATH` environment +/// variable if set, and defaults to looking for `session.json` in the current directory otherwise. +async fn restore_login() -> Result> { + let path = std::env::var("SESSION_FILE").unwrap_or_else(|_| String::from("session.json")); + let file = File::open(&path)?; + let session: Session = serde_json::from_reader(file)?; + let client = client(session.user_id.clone()).await?; + client.restore_login(session).await?; + Ok(client) +} + +async fn sync(client: Client) { + client + .sync_with_callback(SyncSettings::new(), |response| { + let client = client.clone(); + async move { + for (room_id, room) in response.rooms.invite { + for event in room.invite_state.events { + let event = match event.deserialize() { + Ok(event) => event, + Err(_) => continue, + }; + if let AnyStrippedStateEvent::RoomMember(member) = event { + handle_invite(client.clone(), &room_id, member).await + }; + } + } + for (room_id, room) in response.rooms.join { + for event in room.timeline.events { + // skip if serialization failed + let event = match event.event.deserialize() { + Ok(event) => event, + Err(_) => continue, + }; + // Skip if not a text message, otherwise extract the text event + if let AnySyncRoomEvent::Message(AnySyncMessageEvent::RoomMessage( + message, + )) = event + { + handle_message(client.clone(), message.into_full_event(room_id.clone())) + .await + }; + } + } + LoopCtrl::Continue + } + }) + .await; +} + +async fn handle_invite( + client: Client, + room_id: &RoomId, + member: StrippedStateEvent, +) { + // Check that it's an invite, and that it's for our own mxid + if member.state_key != client.user_id().await.unwrap() { + return; + } + let invited = match client.get_invited_room(room_id) { + Some(invited) => invited, + None => return, + }; + // Retry the accept until an house passes because synapse is silly and sends the invite before + // it's ready for receiving a join. + let mut delay = std::time::Duration::from_secs(2); + while invited.accept_invitation().await.is_err() { + tokio::time::sleep(delay).await; + delay *= 2; + if delay.as_secs() > 3600 { + return; + } + } +} + +async fn handle_message(client: Client, message: MessageEvent) { + let text = match message.content.msgtype { + MessageType::Text(ref text) => text, + _ => return, + }; + // Strip the command prefix, skip if it isn't there + let time = match text.body.strip_prefix("!remindme ") { + Some(time) => time, + None => return, + }; + + // Get the room so we can send messages. + let room = client.get_joined_room(&message.room_id).unwrap(); + + // Parse the duration, send message if that fails. + let duration = match ms_converter::ms_into_time(time) { + Ok(time) => time, + Err(_) => { + let _ = room + .send( + AnyMessageEventContent::RoomMessage(MessageEventContent::notice_reply_plain( + "Sorry! I don't understand when you want me to remind you.", + &message, + )), + None, + ) + .await; + return; + } + }; + // Send a reaction so the sender knows their message was received + let _ = room + .send( + AnyMessageEventContent::Reaction(ReactionEventContent::new(ReactionRelation::new( + message.event_id.clone(), + String::from("✅"), + ))), + None, + ) + .await; + tokio::task::spawn(async move { + tokio::time::sleep(duration).await; + // contruct the reminder message + let content = AnyMessageEventContent::RoomMessage(MessageEventContent::notice_reply_plain( + "Here's your reminder!", + &message, + )); + let _ = room.send(content, None).await; + }); +}