diff --git a/src/lib.rs b/src/lib.rs index 5f10aed..f9f4565 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ pub mod date; pub mod email; pub mod hedgedoc; pub mod key_value; +pub mod matrix; pub mod mediawiki; pub mod template; diff --git a/src/main.rs b/src/main.rs index f46a1b5..25b8408 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,7 @@ use cccron_lib::email::{self, Email, SimpleEmail}; use cccron_lib::hedgedoc::{self, HedgeDoc}; use cccron_lib::is_dry_run; use cccron_lib::key_value::{KeyValueStore as KV, KeyValueStore}; +use cccron_lib::matrix::{self, MatrixClient}; use cccron_lib::mediawiki::{self, Mediawiki}; use cccron_lib::NYI; use cccron_lib::{trace_var, trace_var_, verboseln}; @@ -55,7 +56,7 @@ const CONFIG_SPEC: CfgSpec<'static> = CfgSpec { hedgedoc::CONFIG, mediawiki::CONFIG, email::CONFIG, - // TODO: Matrix, …? + matrix::CONFIG, CfgGroup { name: "text", description: "Various strings used.", fields: &[ @@ -470,40 +471,79 @@ fn do_protocol( wiki: &Mediawiki, ) -> Result<(), Box> { NYI!("trace/verbose annotations"); - let (current_pad_id, pad_content, toc, n_topics) = get_pad_info(config, hedgedoc); + let (current_pad_id, pad_content_without_cleanup, toc, n_topics) = get_pad_info(config, hedgedoc); if !toc.is_empty() { let human_date = plenum_day.format("%d.%m.%Y"); - let pad_content = hedgedoc::strip_metadata(pad_content); + let pad_content = hedgedoc::strip_metadata(pad_content_without_cleanup.clone()); let subject = format!("Protokoll vom Plenum am {human_date}"); let pad_content = pad_content.replace("[toc]", &toc); let body = format!( "Anbei das Protokoll vom {human_date}, ab sofort auch im Wiki zu finden.\n\n\ - Das Pad für das nächste Plenum ist zu finden unter <{}/{}>.\nDie Protokolle der letzten Plena findet ihr im wiki unter <{}/index.php?title={}>.\n\n---Protokoll:---\n{pad_content}\n-----", + Das Pad für das nächste Plenum ist zu finden unter <{}/{}>.\nDie Protokolle der letzten Plena findet ihr im wiki unter <{}/index.php?title={}>.\n\n---Protokoll:---\n{}\n-----", &config["hedgedoc-server-url"], &config["hedgedoc-next-id"], &config["wiki-server-url"], - &config["wiki-plenum-page"] + &config["wiki-plenum-page"], + pad_content.clone(), ); let _message_id = send_email(&subject, &body, email, config)?; mediawiki::pad_ins_wiki(pad_content, wiki, plenum_day)?; config.set("state-name", &ProgramState::Logged.to_string()).ok(); } else { let human_date = plenum_day.format("%d.%m.%Y"); - let pad_content = hedgedoc::strip_metadata(pad_content); + let pad_content = hedgedoc::strip_metadata(pad_content_without_cleanup.clone()); let subject = format!("Protokoll vom ausgefallenem Plenum am {human_date}"); let pad_content = pad_content.replace("[toc]", &toc); let body = format!( - "Das letzte Plenum hatte Anbei das Protokoll vom {human_date}, ab sofort auch im Wiki zu finden.\n\n\ - Das Pad für das nächste Plenum ist zu finden unter {}/{}.\nDie Protokolle der letzten Plena findet ihr im wiki unter {}/index.php?title={}.\n\n---Protokoll:---{pad_content}", + "Anbei das Protokoll vom {human_date}, ab sofort auch im Wiki zu finden.\n\n\ + Das Pad für das nächste Plenum ist zu finden unter {}/{}.\nDie Protokolle der letzten Plena findet ihr im wiki unter {}/index.php?title={}.\n\n---Protokoll:---{}", &config["hedgedoc-server-url"], &config["hedgedoc-next-id"], &config["wiki-server-url"], - &config["wiki-plenum-page"] + &config["wiki-plenum-page"], + pad_content.clone(), ); let _message_id = send_email(&subject, &body, email, config)?; mediawiki::pad_ins_wiki(pad_content, wiki, plenum_day)?; config.set("state-name", &ProgramState::Logged.to_string()).ok(); } + let mut matrix = MatrixClient::new( + &config["matrix-homeserver-url"], + &config["matrix-user-id"], + &config["matrix-access-token"], + "!YduwXBXwKifXYApwKF:catgirl.cloud", //&config["room-id-for-short-messages"], + "!YduwXBXwKifXYApwKF:catgirl.cloud", //&config["room-id-for-long-messages"], + is_dry_run(), + ); + // Send the matrix room message + let human_date = plenum_day.format("%d.%m.%Y"); + let pad_content = hedgedoc::strip_metadata(pad_content_without_cleanup); + let pad_content = pad_content.replace("[toc]", &toc); + let long_message = format!( + "Anbei das Protokoll vom {human_date}, ab sofort auch im Wiki zu finden.\n\n\ + Das Pad für das nächste Plenum ist zu finden unter {}/{}.\nDie Protokolle der letzten Plena findet ihr im wiki unter {}/index.php?title={}.\n\n", + &config["hedgedoc-server-url"], + &config["hedgedoc-next-id"], + &config["wiki-server-url"], + &config["wiki-plenum-page"], + ); + let full_long_message = format!( + "{}\n\n{}\n\n{}", + &config["text-email-greeting"], long_message, &config["text-email-signature"] + ); + let short_message = format!( + "Das letzte Plenum hatte Anbei das Protokoll vom {human_date}, ab sofort auch im Wiki zu finden.\n\n\ + Das Pad für das nächste Plenum ist zu finden unter {}/{}.\nDie Protokolle der letzten Plena findet ihr im wiki unter {}/index.php?title={}.\n\n", + &config["hedgedoc-server-url"], + &config["hedgedoc-next-id"], + &config["wiki-server-url"], + &config["wiki-plenum-page"] + ); + let full_short_message = format!( + "{}\n\n{}\n\n{}", + &config["text-email-greeting"], short_message, &config["text-email-signature"] + ); + matrix.send_short_and_long_messages_to_two_rooms(&full_short_message, &full_long_message)?; Ok(()) } diff --git a/src/matrix.rs b/src/matrix.rs new file mode 100644 index 0000000..688a783 --- /dev/null +++ b/src/matrix.rs @@ -0,0 +1,194 @@ +use std::cell::OnceCell; +use std::error::Error; + +use clap::builder::Str; +use colored::Colorize; + +use nom::Err; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::time::{SystemTime, UNIX_EPOCH}; + +use reqwest::blocking::Client; +use serde_json::{json, Value}; + +use crate::config_spec::{CfgField, CfgGroup}; +use crate::{trace_var, verboseln}; + +pub const CONFIG: CfgGroup<'static> = CfgGroup { + name: "matrix", + description: "API Settings for matrix", + fields: &[ + CfgField::Default { + key: "homeserver-url", + default: "https://matrix-client.matrix.org", + description: "Homeserver where the bot logs in.", + }, + CfgField::Default { + key: "user-id", + default: "@bot_username:matrix.org", + description: "API Username associated with the bot account used for writing messages.", + }, + CfgField::Password { + key: "access-token", + description: "Access Token / \"password\" used for authenticating as the bot.", + }, + CfgField::Default { + key: "room-id-for-long-messages", + default: "!someLongRoomIdentifier:matrix.org", + description: "API Username associated with the bot account used for writing messages.", + }, + CfgField::Default { + key: "room-id-for-short-messages", + default: "!someLongRoomIdentifier:matrix.org", + description: "API Username associated with the bot account used for writing messages.", + }, + ], +}; + +pub struct MatrixClient { + homeserver_url: String, + user_id: String, + access_token: String, + is_dry_run: bool, + client: Client, + txn_id: u64, + room_id_for_short_messages: String, + room_id_for_long_messages: String, +} + +#[derive(Serialize, Deserialize, Debug)] +struct LoginRequest { + user: String, + password: String, + #[serde(rename = "type")] + login_type: String, +} + +#[derive(Serialize, Deserialize, Debug)] +struct LoginResponse { + access_token: String, +} + +impl std::fmt::Debug for MatrixClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("matrix") + .field("homeserver_url", &self.homeserver_url) + .field("user_id", &self.user_id) + .field("access_token", &"*****") + .field("is_dry_run", &self.is_dry_run) + .field("client", &self.client) + .finish() + } +} + +impl MatrixClient { + pub fn new( + homeserver_url: &str, user_id: &str, access_token: &str, room_id_for_short_messages: &str, room_id_for_long_messages: &str, is_dry_run: bool, + ) -> Self { + Self { + homeserver_url: homeserver_url.to_string(), + user_id: user_id.to_string(), + access_token: access_token.to_string(), + is_dry_run, + client: Client::builder().cookie_store(true).build().unwrap(), + txn_id: SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(), + room_id_for_long_messages: room_id_for_long_messages.to_string(), + room_id_for_short_messages: room_id_for_short_messages.to_string(), + } + } + fn request( + &self, method: reqwest::Method, endpoint: &str, + query_data: Option<&HashMap>, json_data: Option<&T>, unauth: bool, + ) -> Result> { + let client = reqwest::blocking::Client::new(); + let url = format!("{}/_matrix/client{}", self.homeserver_url, endpoint); + + // Construct URL with query parameters + let mut request = client.request(method, &url); + if let Some(params) = query_data { + request = request.query(params); + } + + // Add JSON body if provided + if let Some(data) = json_data { + request = request.json(data); + } + + // Add authorization header if not unauthenticated request + if !unauth { + request = request.header("Authorization", format!("Bearer {}", self.access_token)); + } + + let response = request.send()?; + + if response.status().is_success() { + Ok(response.json()?) + } else { + Err(format!("Request failed: {}", response.status()).into()) + } + } + + fn put( + &self, endpoint: &str, query_data: Option<&HashMap>, json_data: Option<&T>, + unauth: bool, + ) -> Result> { + self.request(reqwest::Method::PUT, endpoint, query_data, json_data, unauth) + } + + fn post( + &self, endpoint: &str, query_data: Option<&HashMap>, json_data: Option<&T>, + unauth: bool, + ) -> Result> { + self.request(reqwest::Method::POST, endpoint, query_data, json_data, unauth) + } + + fn get( + &self, endpoint: &str, query_data: Option<&HashMap>, unauth: bool, + ) -> Result> { + self.request::>( + reqwest::Method::GET, + endpoint, + query_data, + None, + unauth, + ) + } + + pub fn login(&mut self, username: &str, password: &str) -> Result<(), Box> { + let login_request = LoginRequest { + user: username.to_string(), + password: password.to_string(), + login_type: "m.login.password".to_string(), + }; + + let response: LoginResponse = + serde_json::from_value(self.post("/r0/login", None, Some(&login_request), true)?)?; + + self.access_token = response.access_token; + Ok(()) + } + + fn txn_id(&mut self) -> u64 { + let current = self.txn_id; + self.txn_id += 1; + current + } + + pub fn send_room_message(&mut self, room_id: &str, text: &str) -> Result> { + let content = HashMap::from([("msgtype", "m.text"), ("body", text)]); + self.send_room_event(&room_id, "m.room.message", &content) + } + + fn send_room_event( + &mut self, room: &str, event_type: &str, content: &impl Serialize, + ) -> Result> { + let endpoint = format!("/r0/rooms/{}/send/{}/{}", room, event_type, self.txn_id()); + self.put(&endpoint, None, Some(content), false) + } + pub fn send_short_and_long_messages_to_two_rooms(&mut self, short_message: &str, long_message: &str) -> Result<(), Box> { + self.send_room_message(&long_message, &self.room_id_for_long_messages.clone())?; + self.send_room_message(&short_message, &self.room_id_for_short_messages.clone())?; + Ok(()) + } +} diff --git a/src/mediawiki.rs b/src/mediawiki.rs index b7b761a..416333d 100644 --- a/src/mediawiki.rs +++ b/src/mediawiki.rs @@ -1,5 +1,6 @@ use std::cell::OnceCell; use std::error::Error; +use std::f64::consts::E; use chrono::{Datelike, NaiveDate, Utc}; use colored::Colorize; @@ -75,13 +76,13 @@ impl std::fmt::Debug for Mediawiki { pub enum ValidRequestTypes { Get, Post, - PostForEditing + PostForEditing, } pub enum ValidPageEdits { WithPotentiallyOverriding, WithoutOverriding, - ModifyPlenumPageAfterwards_WithoutOverriding + ModifyPlenumPageAfterwards_WithoutOverriding, } impl Mediawiki { @@ -223,7 +224,6 @@ impl Mediawiki { resp } - /// Creates a completely new wiki page with page_content and page_title as inputs pub fn new_wiki_page( &self, page_title: &str, page_content: &str, update_main_page: ValidPageEdits, @@ -243,7 +243,7 @@ impl Mediawiki { } let url = format!("{}/api.php", self.server_url); - + let params: Box<[(&str, &str)]> = match update_main_page { ValidPageEdits::WithPotentiallyOverriding => { // This means we *EDIT* the *Main Page* and do not prevent overwriting @@ -253,9 +253,9 @@ impl Mediawiki { ("title", page_title), ("text", page_content), ("token", self.csrf_token.get().unwrap()), - ("bot", "true") - ]) - } + ("bot", "true"), + ]) + }, ValidPageEdits::ModifyPlenumPageAfterwards_WithoutOverriding => { // This means we *CREATE* a *new Page* and always prevent overwriting Box::from([ @@ -267,7 +267,7 @@ impl Mediawiki { ("createonly", "true"), // Prevent overwriting existing pages ("bot", "true"), ]) - } + }, ValidPageEdits::WithoutOverriding => { // This means we *CREATE* a *new Page* and always prevent overwriting Box::from([ @@ -279,7 +279,7 @@ impl Mediawiki { ("createonly", "true"), // Prevent overwriting existing pages ("bot", "true"), ]) - } + }, }; verboseln!("Current page title: {page_title}"); @@ -294,11 +294,19 @@ impl Mediawiki { let response_result = serde_json::from_str::(&request_result); let response = response_result.unwrap_or_else(|e| { print!("Error while creating new wiki page:\n{}", e.to_string().cyan()); - return serde_json::from_str("\"(error)\"").unwrap() + return serde_json::from_str("\"(error)\"").unwrap(); }); verboseln!("pos2"); // Check if the page creation was successful - if let Some(edit) = response.get("edit") { + if let Some(error) = response.get("error") { + if let Some(info) = error.get("info") { + if info == "The page you tried to create has been created already." { + verboseln!("The page you tried to create has been created already. Continuing...") + } else { + println!("There was an error while editing the wiki: {}", info.to_string().yellow()) + } + } + } else if let Some(edit) = response.get("edit") { verboseln!("pos3"); if edit.get("result").and_then(|r| r.as_str()) == Some("Success") { verboseln!("Successfully created wiki page: {}", page_title); @@ -318,9 +326,9 @@ impl Mediawiki { verboseln!("updating main page..."); self.update_plenum_page(page_title)? }, - _ => () + _ => (), }; - return Ok(page_title.to_string()) + return Ok(page_title.to_string()); } /// This function is responsible for updating the main plenum page: @@ -339,7 +347,6 @@ impl Mediawiki { // check first if the script has been run before and if the link already exists if !page_content.contains(&new_page_title_to_link_to) { - // check if the current year heading pattern exists if !page_content.contains(&year_heading_pattern) { // If not, add a new year heading pattern @@ -375,7 +382,11 @@ impl Mediawiki { verboseln!("{}", "The bot appears to have been run before and a duplicate link to the new plenum pad was avoided.".yellow()) } // refresh page - self.new_wiki_page(&self.plenum_main_page_name, &page_content, ValidPageEdits::WithPotentiallyOverriding)?; + self.new_wiki_page( + &self.plenum_main_page_name, + &page_content, + ValidPageEdits::WithPotentiallyOverriding, + )?; Ok(()) } /// This function downloads and returns the contents of a wiki page when given the page's title (e.g. `page_title = Plenum/13._August_2024`) @@ -512,7 +523,11 @@ pub fn pad_ins_wiki( let page_title = create_page_title(date); let full_page_title = format!("{}/{}", wiki.plenum_main_page_name, page_title); - wiki.new_wiki_page(&full_page_title, &pad_converted, ValidPageEdits::ModifyPlenumPageAfterwards_WithoutOverriding)?; + wiki.new_wiki_page( + &full_page_title, + &pad_converted, + ValidPageEdits::ModifyPlenumPageAfterwards_WithoutOverriding, + )?; verboseln!("Finished successfully with wiki"); Ok(())