From d84b4c3466adcc085b7df36015df4b46488ba591 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sat, 6 Mar 2021 12:12:03 +0100 Subject: add proper previous-log parsing for matrix Doing all the "new log" insertion based on simple string operations is a bit of madness, so the proper course of action is to parse them into a proper intermediate representation from which we can then generate a plain and HTML body. In addition, this has some other minor code cleanup for the matrix module. --- src/logbag.rs | 288 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + src/matrix.rs | 196 ++++++++------------------------------- 3 files changed, 326 insertions(+), 159 deletions(-) create mode 100644 src/logbag.rs (limited to 'src') diff --git a/src/logbag.rs b/src/logbag.rs new file mode 100644 index 0000000..f461007 --- /dev/null +++ b/src/logbag.rs @@ -0,0 +1,288 @@ +use std::iter; +use std::str::FromStr; + +use evtclib::{Log, Outcome}; +use itertools::Itertools; + +/// A [`LogBag`] is a struct that holds multiple logs in their categories. +/// +/// This is similar to hash map mapping a category to a list of logs, but the [`LogBag`] saves its +/// insertion order. The logs are saved as a line, that way we don't have to re-parse or re-upload +/// them and we can just handle arbitrary data. +#[derive(Default, Debug, Clone, PartialEq, Eq, Hash)] +pub struct LogBag { + data: Vec<(String, Vec)>, +} + +impl LogBag { + /// Construct a new, empty [`LogBag`]. + pub fn new() -> Self { + LogBag { data: Vec::new() } + } + + /// Return an iterator over all available categories. + pub fn categories(&self) -> impl Iterator { + self.data.iter().map(|x| &x.0 as &str) + } + + /// Return an iterator over (category, items). + pub fn iter(&self) -> impl Iterator)> { + self.data + .iter() + .map(|(cat, lines)| (cat as &str, lines.iter().map(|l| l as &str))) + } + + /// Insert an item into the given category. + /// + /// If the category does not exist yet, it will be appended at the bottom. + pub fn insert(&mut self, category: &str, line: String) { + for (cat, lines) in self.data.iter_mut() { + if cat == category { + lines.push(line); + return; + } + } + + // When we reach here, we don't have the category yet, so we gotta insert it. + self.data.push((String::from(category), vec![line])); + } + + /// Tries to parse the given text as a plain [`LogBag`]. + pub fn parse_plain(input: &str) -> Option { + input.parse().ok() + } + + /// Renders the contents of this [`LogBag`] as plain text. + /// + /// The output of this can be fed back into [`LogBag::parse_plain`] to round-trip. + pub fn render_plain(&self) -> String { + self.iter() + .map(|(category, lines)| iter::once(category).chain(lines).join("\n")) + .join("\n\n") + } + + /// Renders the contents of this [`LogBag`] as HTML. + /// + /// Useful for posting to Matrix chats. + pub fn render_html(&self) -> String { + self.iter() + .map(|(category, mut lines)| { + format!("{}
\n{}", category, lines.join("
\n")) + }) + .join("
\n
\n") + } + + /// Renders the contents of this [`LogBag`] as Markdown. + /// + /// Useful for posting to Discord chats. + pub fn render_markdown(&self) -> String { + self.iter() + .map(|(category, mut lines)| format!("**{}**\n{}", category, lines.join("\n"))) + .join("\n\n") + } +} + +impl FromStr for LogBag { + type Err = (); + + fn from_str(s: &str) -> Result { + let data = s + .trim() + .split("\n\n") + .map(|chunk| { + let mut lines = chunk.split('\n'); + let category = lines.next().unwrap(); + ( + category.to_string(), + lines.map(ToString::to_string).collect::>(), + ) + }) + .collect(); + Ok(LogBag { data }) + } +} + +impl From)>> for LogBag { + fn from(data: Vec<(String, Vec)>) -> Self { + LogBag { data } + } +} + +/// A helper function to return the right emoji for a given log. +pub fn state_emoji(log: &Log) -> &'static str { + let outcome = log.analyzer().and_then(|a| a.outcome()); + match outcome { + Some(Outcome::Success) => "✅", + Some(Outcome::Failure) => "❌", + None => "❓", + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn insert() { + let mut logbag = LogBag::new(); + logbag.insert("cat 1", "line 1".to_string()); + assert_eq!(logbag.categories().count(), 1); + logbag.insert("cat 1", "line 2".to_string()); + assert_eq!(logbag.categories().count(), 1); + logbag.insert("cat 2", "line 1".to_string()); + assert_eq!(logbag.categories().count(), 2); + + assert_eq!( + logbag.categories().collect::>(), + vec!["cat 1", "cat 2"] + ); + } + + #[test] + fn parse_single() { + let mut logbag = LogBag::new(); + logbag.insert("cat 1", "line 1".to_string()); + logbag.insert("cat 1", "line 2".to_string()); + + assert_eq!( + LogBag::parse_plain( + "\ +cat 1 +line 1 +line 2" + ), + Some(logbag) + ); + } + + #[test] + fn parse_multi() { + let mut logbag = LogBag::new(); + logbag.insert("cat 1", "line 1".to_string()); + logbag.insert("cat 1", "line 2".to_string()); + logbag.insert("cat 2", "line 1".to_string()); + logbag.insert("cat 2", "line 2".to_string()); + + assert_eq!( + LogBag::parse_plain( + "\ +cat 1 +line 1 +line 2 + +cat 2 +line 1 +line 2" + ), + Some(logbag) + ); + } + + #[test] + fn render_plain_single() { + let mut logbag = LogBag::new(); + logbag.insert("category", "line 1".to_string()); + logbag.insert("category", "line 2".to_string()); + + assert_eq!( + logbag.render_plain(), + "\ +category +line 1 +line 2" + ); + } + + #[test] + fn render_plain_multi() { + let mut logbag = LogBag::new(); + logbag.insert("category 1", "line 1".to_string()); + logbag.insert("category 1", "line 2".to_string()); + logbag.insert("category 2", "enil 1".to_string()); + logbag.insert("category 2", "enil 2".to_string()); + + assert_eq!( + logbag.render_plain(), + "\ +category 1 +line 1 +line 2 + +category 2 +enil 1 +enil 2" + ); + } + + #[test] + fn render_html_single() { + let mut logbag = LogBag::new(); + logbag.insert("category", "line 1".to_string()); + logbag.insert("category", "line 2".to_string()); + + assert_eq!( + logbag.render_html(), + "\ +category
+line 1
+line 2" + ); + } + + #[test] + fn render_html_multi() { + let mut logbag = LogBag::new(); + logbag.insert("category 1", "line 1".to_string()); + logbag.insert("category 1", "line 2".to_string()); + logbag.insert("category 2", "enil 1".to_string()); + logbag.insert("category 2", "enil 2".to_string()); + + assert_eq!( + logbag.render_html(), + "\ +category 1
+line 1
+line 2
+
+category 2
+enil 1
+enil 2" + ); + } + + #[test] + fn render_markdown_single() { + let mut logbag = LogBag::new(); + logbag.insert("category", "line 1".to_string()); + logbag.insert("category", "line 2".to_string()); + + assert_eq!( + logbag.render_markdown(), + "\ +**category** +line 1 +line 2" + ); + } + + #[test] + fn render_markdown_multi() { + let mut logbag = LogBag::new(); + logbag.insert("category 1", "line 1".to_string()); + logbag.insert("category 1", "line 2".to_string()); + logbag.insert("category 2", "enil 1".to_string()); + logbag.insert("category 2", "enil 2".to_string()); + + assert_eq!( + logbag.render_markdown(), + "\ +**category 1** +line 1 +line 2 + +**category 2** +enil 1 +enil 2" + ); + } +} diff --git a/src/main.rs b/src/main.rs index 43c98b1..ab40d86 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,6 +21,7 @@ use categories::Categorizable; mod config; use config::Config; mod discord; +mod logbag; mod matrix; const DPS_REPORT_API: &str = "https://dps.report/uploadContent"; diff --git a/src/matrix.rs b/src/matrix.rs index bd83a1f..ffd223d 100644 --- a/src/matrix.rs +++ b/src/matrix.rs @@ -1,11 +1,12 @@ 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, Outcome}; +use evtclib::Log; use log::{debug, info}; use tokio::runtime::Runtime; @@ -37,6 +38,15 @@ impl From for MatrixUser { } } +/// Maximum age of the message to still be edited. +const MAX_HOURS: u64 = 5; + +/// 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 mut rt = Runtime::new()?; let room_id = RoomId::try_from(room_id)?; @@ -62,8 +72,9 @@ pub fn post_link(user: MatrixUser, room_id: &str, log: &Log, link: &str) -> Resu } Some((old_id, old_text)) => { debug!("Updating message {:?}", old_id); - let new_text = insert_log(&old_text, log, link); - let new_html = htmlify(&new_text); + 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?; } } @@ -81,15 +92,15 @@ async fn find_message( room_id: &RoomId, ) -> Result> { let request = get_message_events::Request::backward(room_id, ""); - let five_h_ago = SystemTime::now() - Duration::from_secs(5 * 60 * 60); + let time_limit = SystemTime::now() - Duration::from_secs(MAX_HOURS * 60 * 60); for raw_message in client.room_messages(request).await?.chunk { - if let Ok(message) = raw_message.deserialize() { - if let AnyRoomEvent::Message(AnyMessageEvent::RoomMessage(msg)) = message { - if &msg.sender == my_id && msg.origin_server_ts >= five_h_ago { - if let MessageEventContent::Text(text) = msg.content { - if text.relates_to.is_none() { - return Ok(Some((msg.event_id, text.body))); - } + if let Ok(AnyRoomEvent::Message(AnyMessageEvent::RoomMessage(msg))) = + raw_message.deserialize() + { + if &msg.sender == my_id && msg.origin_server_ts >= time_limit { + if let MessageEventContent::Text(text) = msg.content { + if text.relates_to.is_none() { + return Ok(Some((msg.event_id, text.body))); } } } @@ -98,11 +109,12 @@ async fn find_message( 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 title = log.category(); let line = format!("{} {}", state_emoji(log), link); - let body = format!("{}\n{}\n", title, line); - let html = format!("{}
\n{}\n", title, line); + let logbag: LogBag = vec![(log.category().to_string(), vec![line])].into(); + let body = logbag.render_plain(); + let html = logbag.render_html(); let text_message = TextMessageEventContent::html(body, html); client @@ -115,6 +127,9 @@ async fn post_new(client: &Client, room_id: &RoomId, log: &Log, link: &str) -> R 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, @@ -139,149 +154,12 @@ async fn update_message( Ok(()) } -fn insert_log(old_text: &str, log: &Log, link: &str) -> String { - let chunks = old_text.split("\n\n"); - let mut found = false; - let result = chunks - .map(|chunk| { - let category = chunk.split("\n").next().unwrap(); - if category == log.category() { - found = true; - format!("{}\n{} {}", chunk.trim(), state_emoji(log), link) - } else { - chunk.to_string() - } - }) - .collect::>() - .join("\n\n"); - if found { - result - } else { - format!( - "{}\n\n{}\n{} {}", - result.trim(), - log.category(), - state_emoji(log), - link - ) - } -} - -fn htmlify(text: &str) -> String { - text.split("\n\n") - .map(|chunk| { - let lines = chunk.split("\n").collect::>(); - format!("{}
\n{}", lines[0], lines[1..].join("
\n")) - }) - .collect::>() - .join("
\n
\n") -} - -fn state_emoji(log: &Log) -> &'static str { - let outcome = log.analyzer().and_then(|a| a.outcome()); - match outcome { - Some(Outcome::Success) => "✅", - Some(Outcome::Failure) => "❌", - None => "❓", - } -} - -#[cfg(test)] -mod test { - use super::*; - use std::io::Cursor; - use evtclib::Compression; - - static ARKK_LOG: &[u8] = include_bytes!("../test/logs/arkk.zevtc"); - - fn arkk_log() -> Log { - evtclib::process_stream(Cursor::new(ARKK_LOG), Compression::Zip).unwrap() - } - - #[test] - fn test_insert_log_new_category() { - let log = arkk_log(); - let old_text = "\ -Unknown -x https://dps.report.example"; - let new_text = insert_log(old_text, &log, "foobar"); - - assert_eq!("\ -Unknown -x https://dps.report.example - -99 CM (Shattered Observatory) -✅ foobar", new_text); - } - - #[test] - fn test_insert_log_appending() { - let log = arkk_log(); - let old_text = "\ -99 CM (Shattered Observatory) -x old log here"; - let new_text = insert_log(old_text, &log, "foobar"); - assert_eq!("\ -99 CM (Shattered Observatory) -x old log here -✅ foobar", new_text); - } - - #[test] - fn test_insert_log_multiple() { - let log = arkk_log(); - let old_text = "\ -100 CM (Sunqua Peak) -y some old log here - -99 CM (Shattered Observatory) -x old log here - -98 CM (Nightmare) -z raboof"; - let new_text = insert_log(old_text, &log, "foobar"); - assert_eq!("\ -100 CM (Sunqua Peak) -y some old log here - -99 CM (Shattered Observatory) -x old log here -✅ foobar - -98 CM (Nightmare) -z raboof", new_text); - } - - #[test] - fn test_htmlify_single() { - let plain = "\ -Header -Log 1 -Log 2"; - assert_eq!(htmlify(plain), "\ -Header
-Log 1
-Log 2"); - } - - #[test] - fn test_htmlify_multiple() { - let plain = "\ -Header 1 -Log 1 -Log 2 - -Header 2 -Log 3 -Log 4"; - assert_eq!(htmlify(plain), "\ -Header 1
-Log 1
-Log 2
-
-Header 2
-Log 3
-Log 4"); - } - +/// 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 } -- cgit v1.2.3