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; }); }