use super::categories::Categorizable; use super::config; use super::logbag::{state_emoji, LogBag}; use std::convert::TryFrom; use std::time::{Duration, SystemTime}; use anyhow::Result; use evtclib::Log; use log::{debug, info}; use tokio::runtime::Runtime; use reqwest::Url; use ruma::{ api::client::r0::message::get_message_events, events::room::message::{MessageEventContent, MessageType, Relation, Replacement}, events::{AnyMessageEvent, AnyMessageEventContent, AnyRoomEvent}, identifiers::{EventId, RoomId, UserId}, MilliSecondsSinceUnixEpoch, UInt, }; use matrix_sdk::Client; #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct MatrixUser { pub homeserver: Url, pub username: String, pub password: String, pub device_id: Option, } impl From for MatrixUser { fn from(matrix: config::Matrix) -> Self { MatrixUser { homeserver: matrix.homeserver, username: matrix.username, password: matrix.password, device_id: matrix.device_id, } } } /// Maximum age of the message to still be edited. const MAX_HOURS: u64 = 5; /// Amount of messages to be loaded in one chunk. const MESSAGE_CHUNK_SIZE: u16 = 20; /// Number of message chunks that should be loaded (in total) when searching for the last message. const MESSAGE_CHUNK_COUNT: u16 = 3; /// Posts a link to the log to a Matrix room. /// /// The user identification is given in the `user` parameter. /// /// This function blocks until all API calls have been made, that is until the message has reached /// the homeserver. pub fn post_link(user: MatrixUser, room_id: &str, log: &Log, link: &str) -> Result<()> { let rt = Runtime::new()?; let room_id = RoomId::try_from(room_id)?; rt.block_on(async { let client = Client::new(user.homeserver)?; let my_data = client .login( &user.username, &user.password, user.device_id.as_ref().map(|x| x as &str), None, ) .await?; info!("Matrix connected as {:?}", my_data.user_id); let old_msg = find_message(&client, &my_data.user_id, &room_id).await?; match old_msg { None => { debug!("Creating a fresh message for matrix"); post_new(&client, &room_id, log, link).await?; } Some((old_id, old_text)) => { debug!("Updating message {:?}", old_id); let logbag = insert_log(&old_text, log, link); let new_text = logbag.render_plain(); let new_html = logbag.render_html(); update_message(&client, &room_id, &old_id, &new_text, &new_html).await?; } } Ok(()) }) } /// Finds the right message if there is one to edit. /// /// Either returns the message ID and the old message text, or None if no suitable message was /// found. async fn find_message( client: &Client, my_id: &UserId, room_id: &RoomId, ) -> Result> { let limit = UInt::try_from(MESSAGE_CHUNK_SIZE).unwrap(); let time_limit = MilliSecondsSinceUnixEpoch::from_system_time( SystemTime::now() - Duration::from_secs(MAX_HOURS * 60 * 60), ) .expect("Our time limit is before the epoch"); let mut continue_from = String::new(); for chunk_nr in 0..MESSAGE_CHUNK_COUNT { debug!( "Loading {} items (chunk {} of {})", MESSAGE_CHUNK_SIZE, chunk_nr + 1, MESSAGE_CHUNK_COUNT ); let mut request = get_message_events::Request::backward(room_id, &continue_from); request.limit = limit; let response = client.send(request, None).await?; for raw_message in response.chunk { if let Ok(AnyRoomEvent::Message(AnyMessageEvent::RoomMessage(msg))) = raw_message.deserialize() { if &msg.sender == my_id && msg.origin_server_ts >= time_limit { if let MessageType::Text(text) = msg.content.msgtype { if !matches!( msg.content.relates_to, Some(Relation::Reply { .. } | Relation::Replacement(..)) ) { return Ok(Some((msg.event_id, text.body))); } } } } } match response.end { Some(token) => continue_from = token, None => break, } } Ok(None) } /// Post a new message to the given Matrix channel. async fn post_new(client: &Client, room_id: &RoomId, log: &Log, link: &str) -> Result<()> { let line = format!("{} {}", state_emoji(log), link); let logbag: LogBag = vec![(log.category().to_string(), vec![line])].into(); let body = logbag.render_plain(); let html = logbag.render_html(); client .room_send( room_id, AnyMessageEventContent::RoomMessage(MessageEventContent::text_html(body, html)), None, ) .await?; Ok(()) } /// Updates the given message with some new text /// /// This constructs and sends the right Matrix API message. async fn update_message( client: &Client, room_id: &RoomId, old_id: &EventId, new_text: &str, new_html: &str, ) -> Result<()> { let mut message = MessageEventContent::text_html(new_text, new_html); message.relates_to = Some(Relation::Replacement(Replacement::new( old_id.clone(), Box::new(MessageEventContent::text_html(new_text, new_html)), ))); client .room_send(room_id, AnyMessageEventContent::RoomMessage(message), None) .await?; Ok(()) } /// Inserts the given log into the text. /// /// The text is first parsed as a [`LogBag`], then the link is formatted and inserted. fn insert_log(old_text: &str, log: &Log, link: &str) -> LogBag { let line = format!("{} {}", state_emoji(log), link); let mut logbag = LogBag::parse_plain(old_text).unwrap(); logbag.insert(log.category(), line); logbag }