Initial commit
This commit is contained in:
parent
929984ff0c
commit
45212da497
15
Cargo.toml
Normal file
15
Cargo.toml
Normal file
|
@ -0,0 +1,15 @@
|
|||
[package]
|
||||
name = "reminder-bot"
|
||||
version = "0.1.0"
|
||||
authors = ["Amanda Graven <amanda@amandag.net>"]
|
||||
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"]}
|
7
rustfmt.toml
Normal file
7
rustfmt.toml
Normal file
|
@ -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"
|
5
session.sample.json
Normal file
5
session.sample.json
Normal file
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"access_token": "asdf1234",
|
||||
"user_id": "@alice:matrix.org",
|
||||
"device_id": "ABCDEFGHIJ"
|
||||
}
|
196
src/main.rs
Normal file
196
src/main.rs
Normal file
|
@ -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<Client> {
|
||||
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<Session, Box<dyn std::error::Error>> {
|
||||
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<Client, Box<dyn std::error::Error>> {
|
||||
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<MemberEventContent>,
|
||||
) {
|
||||
// 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;
|
||||
});
|
||||
}
|
Loading…
Reference in a new issue