From 9f38a41b4642988bfdf05532d15443fe6b14c9cb Mon Sep 17 00:00:00 2001 From: nobody Date: Mon, 29 Jul 2024 15:12:26 +0200 Subject: [PATCH] basic arg parsing + config check mode --- src/config_check.rs | 208 ++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 184 ++++++++++++++++++++------------------- 2 files changed, 302 insertions(+), 90 deletions(-) create mode 100644 src/config_check.rs diff --git a/src/config_check.rs b/src/config_check.rs new file mode 100644 index 0000000..8755485 --- /dev/null +++ b/src/config_check.rs @@ -0,0 +1,208 @@ +use crate::key_value::KeyValueStore as KV; +use std::error::Error; +use std::io::{self, Write}; + +const CONFIG_SPEC: CfgSpec<'_> = CfgSpec { + groups: &[ + CfgGroup { name: "config", + description: "(internal values)", + fields: &[ + CfgField::Silent { key: "version", default: "1" }, + ], + }, + CfgGroup { name: "hedgedoc", + description: "HedgeDoc markdown pad server settings", + fields: &[ + CfgField::Default { key: "server-url", default: "https://md.berlin.ccc.de/", description: "Hedgedoc server storing the pads." }, + CfgField::Default { key: "template-name", default: "plenum-template", description: "Name of the pad containing the template to use." }, + // TODO: make these generators? + CfgField::Optional { key: "last-id", description: "ID of last plenum's pad." }, + CfgField::Optional { key: "next-id", description: "ID of next plenum's pad." } + ], + }, + CfgGroup { name: "email", + description: "Sending emails.", + fields: &[ + CfgField::Default { key: "server", default: "mail.berlin.ccc.de", description: "SMTP server used for sending emails." }, + CfgField::Default { key: "user", default: "plenum-bot@berlin.ccc.de", description: "User name used for authenticating with the mail server." }, + CfgField::Password { key: "password", description: "Password for authenticating with the mail server." }, + CfgField::Default { key: "sender", default: "Plenumsbot ", description: "Email address to use for \"From:\"." }, + CfgField::Default { key: "to", default: "CCCB Intern ", description: "Recipient of the emails sent." }, + CfgField::Optional { key: "last-message-id", description: "Message-Id of last initial announcement to send In-Reply-To (if applicable)." } + ], + }, + // TODO: Matrix, Wiki + CfgGroup { name: "text", + description: "Various strings used.", + fields: &[ + CfgField::Default { key: "email-signature", + default: "\n\n[Diese Nachricht wurde automatisiert vom Plenumsbot erstellt und ist daher ohne Unterschrift gültig.]", + description: "Text added at the bottom of every email." + }, + ], + }, + ], +}; + +/// Ensure all fields with known default values exist. +pub fn populate_defaults(config: &KV) { + for group in CONFIG_SPEC.groups { + for field in group.fields { + match field { + CfgField::Silent { key, default, .. } => { + config.default(&format!("{}-{}", group.name, key), default) + }, + CfgField::Default { key, default, .. } => { + config.default(&format!("{}-{}", group.name, key), default) + }, + _ => {} + } + } + } +} + +const ANSI_GROUP: &str = "\x1b[1;31m"; +const ANSI_FIELD: &str = "\x1b[1;33m"; +const ANSI_RESET: &str = "\x1b[0m"; + +pub fn interactive_check(config: KV) -> Result<(), Box> { + for group in CONFIG_SPEC.groups { + group_header( group.name, group.description ); + // TODO: skip category option? + for field in group.fields { + check_field( &config, group.name, field )?; + } + } + Ok(()) +} + +fn check_field( config: &KV, grpname: &str, field: &CfgField ) -> Result<(), Box> { + if field.is_silent() { return Ok(()) } + let key = field.full_key(grpname); + println!( "{}{}{} - {}", ANSI_FIELD, &key, ANSI_RESET, field.description() ); + println!( " default: {}", field.default().unwrap_or("(empty)".to_string())); + let value = config.get(&key).ok(); + if field.is_password() { + match value { + Some(_) => println!(" current: *****"), + None => println!(" current: (empty)"), + }; + //todo!() + // TODO: [K]eep as-is (if exists), [R]emove, [I]nput + } else { + println!( " current: {}", value.unwrap_or("(empty)".to_string()) ); + // TODO: [K]eep as-is, [R]eset to default, [I]nput, [L]ong (multiline) input + } + // Action - + Ok(()) +} + +fn group_header( name: &str, description: &str ) { + println!("=============================="); + println!("{}{}{} - {}", ANSI_GROUP, name, ANSI_RESET, description); + println!(""); +} + +fn prompt_single_line( ) -> String { + print!("New value: "); + io::stdout().flush().ok(); + let mut input = String::new(); + io::stdin().read_line(&mut input).unwrap(); + input.trim().to_string() +} + +fn prompt_multiline( ) -> String { + println!("Enter new value: (end with '.' on a new line)"); + let mut acc = String::new(); + loop { + let mut line = String::new(); + io::stdin().read_line(&mut line).unwrap(); + if line.trim() == "." { + break; + } + acc.push_str(&line); + } + acc +} + +fn prompt_password( ) -> String { + rpassword::prompt_password("New password (not shown) : ").unwrap() +} + +/// Describes a field in the configuration. +enum CfgField<'a> { + /// Silently writes a default to the DB if absent, never prompts for changes. + Silent { key: &'a str, default: &'a str }, + /// Writes a default, asks for changes. + Default { key: &'a str, default: &'a str, description: &'a str }, + /// No default, asks for value, but can stay empty. + Optional { key: &'a str, description: &'a str }, + /// Empty by default, required, will prompt without echo. + Password { key: &'a str, description: &'a str }, + /// Empty by default, can be user-provided, or will be generated randomly. + RandomId { key: &'a str, generator: fn() -> String, generator_description: &'a str, description: &'a str }, +} + +/// A group of related config fields. The final key of an inner value will be +/// `{group.name}-{key}`. +struct CfgGroup<'a> { + name: &'a str, + description: &'a str, + fields: &'a [CfgField<'a>], +} + +/// A description of the entire config, as a collection of named groups. +/// +/// Used both to populate defaults and to interactively adjust the configuration. +struct CfgSpec<'a> { + groups: &'a [CfgGroup<'a>], +} + +impl<'a> CfgField<'a> { + fn is_silent(&self) -> bool { + match self { + CfgField::Silent { .. } => true, + _ => false + } + } + + fn is_password(&self) -> bool { + match self { + CfgField::Password { .. } => true, + _ => false + } + } + + fn key(&self) -> String { + match self { + CfgField::Silent { key, .. } => key, + CfgField::Default { key, .. } => key, + CfgField::Optional { key, .. } => key, + CfgField::Password { key, .. } => key, + CfgField::RandomId { key, .. } => key, + }.to_string() + } + + fn full_key(&self, grpname: &str) -> String { + format!("{}-{}", grpname, self.key()) + } + + fn description(&self) -> String { + match self { + CfgField::Silent { .. } => "(none)", + CfgField::Default { description, .. } => description, + CfgField::Optional { description, .. } => description, + CfgField::Password { description, .. } => description, + CfgField::RandomId { description, .. } => description, + }.to_string() + } + + fn default(&self) -> Option { + match self { + CfgField::Silent { default, .. } => Some(default.to_string()), + CfgField::Default { default, .. } => Some(default.to_string()), + CfgField::RandomId { generator_description, .. } => Some(format!("{}{}{}",'(',generator_description,')')), + _ => None, + } + } +} diff --git a/src/main.rs b/src/main.rs index f179eef..f5dfee7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,42 +19,33 @@ Pad-ins-Wiki-und-versenden-Skript • Neue Wiki-Seite erstellen und dort das umgewandelte Pad hochladen */ -use std::borrow::Cow; -use std::collections::HashMap; -use std::error::Error; -use std::fs::File; -use std::io::prelude::*; - -use api::Api; -use chrono::{Datelike, Local, NaiveDate, Weekday}; -// MAIL START -use lettre::{Message, SmtpTransport, Transport}; -use lettre::message::{header, SinglePart}; -use lettre::transport::smtp::authentication::Credentials; -// For MediaWiki-conversion -use pandoc; -use regex::Regex; -use reqwest::blocking::{Client, get, Response}; -use reqwest::header as rqwheader; -use serde::Deserialize; -use uuid::Uuid; -use mediawiki; -use base64::Engine; - -use key_value::KeyValueStore as KV; - // Import other .rs files as modules mod key_value; +use key_value::KeyValueStore as KV; + +mod config_check; mod create_new_pads; pub mod variables_and_settings; +use std::borrow::Cow; +use std::error::Error; +use std::env; +use std::io; +use std::io::prelude::*; +use std::fs::File; // use std::future::Future; -// use std::process::Command; -// use headers::ContentType; +use chrono::{Datelike, Local, NaiveDate, Weekday}; +use clap::{Arg, Command}; +use regex::Regex; +use reqwest::Client; +use uuid::Uuid; +use pandoc::{MarkdownExtension, Pandoc, PandocError, PandocOutput}; -// MAIL END +use lettre::{Message, SmtpTransport, Transport}; +use lettre::message::{header, SinglePart}; +use lettre::transport::smtp::authentication::Credentials; #[derive(PartialEq)] enum EMailOperationStates { @@ -67,22 +58,59 @@ const PLENUM_TEMPLATE_URL: &str = variables_and_settings::PLENUM_TEMPLATE_URL; const FALLBACK_TEMPLATE: &str = variables_and_settings::FALLBACK_TEMPLATE; const TESTING_MODE: EMailOperationStates = EMailOperationStates::Test; -fn kv_defaults (kv: &KV) { - kv.default("hedgedoc-server-url","https://md.berlin.ccc.de/"); - kv.default("hedgedoc-template-name","plenum-template"); - // hedgedoc-last-id, hedgedoc-next-id have no defaults - kv.default("email-server","mail.berlin.ccc.de"); - kv.default("email-user","plenum-bot@berlin.ccc.de"); - kv.default("email-name","Plenumsbot"); - // add email-pass and email-to manually - // email-last-message-id has no default - kv.default("email-signature", "\n\n[Diese Nachricht wurde automatisiert vom Plenumsbot erstellt und ist daher ohne Unterschrift gültig.]"); +/* ***** Runtime Configuration (Arguments & Environment Variables) ***** */ + +/// Checks environment variable `DRY_RUN` to see if any external operations +/// should *actually* be done. +/// +/// If `is_dry_run` returns `true`, just report what you *would* do, and +/// don't actually e.g. send emails. +fn is_dry_run() -> bool { + env::var("DRY_RUN").map(|v| !v.is_empty()).unwrap_or(false) } -fn main() -> Result<(), Box> { +struct Args { + check_mode: bool, + config_file: String, +} +fn parse_args() -> Args { + let matches = Command::new("Plenum-Bot") + .about("CCCB Plenumsbot") + .arg( + Arg::new("check") + .short('c') + .long("check") + .action(clap::ArgAction::SetTrue) + .help("interactively check the config for missing values"), + ) + .arg( + Arg::new("config_file") + .short('f') + .long("config-file") + .value_name("FILE") + .action(clap::ArgAction::Set) + .default_value("config.sqlite") + .help("specifies an alternate config file"), + ).get_matches(); + Args { + check_mode: *matches.get_one::("check").unwrap(), + config_file: matches.get_one::("config_file").unwrap().clone(), + } +} + +/* ***** Main ***** */ + +#[tokio::main] +async fn main() -> Result<(), Box> { + // set up config file access + let args = parse_args(); + let config_file = args.config_file.as_str(); + let config = KV::new(config_file).unwrap(); + config_check::populate_defaults(&config); + if args.check_mode { + return config_check::interactive_check( config ); + } // config - let config = KV::new("plenum_config.sqlite").unwrap(); - kv_defaults(&config); let hedgedoc_server = &config["hedgedoc-server-url"]; println!("[START]\nAktueller Zustand der DB:"); config.dump_redacting(&["email-pass","matrix-pass"]).ok(); @@ -145,7 +173,7 @@ fn main() -> Result<(), Box> { let yesterday_was_plenum = true; // Das ist nur zu Testzwecken, kommt noch weg if in_1_day_is_plenum { println!("In 1 Tag ist Plenum, deshalb wird eine Erinnerung raus geschickt!"); - let pad_content = download_and_return_pad(format!("{}/download", current_pad_link.clone())).expect("Fehler beim Download des Pads!"); + let pad_content = download_and_return_pad(format!("{}/download", current_pad_link.clone())).await.expect("Fehler beim Download des Pads!"); let pad_content_without_top_instructions = try_to_remove_top_instructions(pad_content); let tldr_vec = create_tldr(&pad_content_without_top_instructions); let mut tldr = String::new(); @@ -169,7 +197,7 @@ fn main() -> Result<(), Box> { } } else if in_3_days_is_plenum { println!("In 3 Tagen ist Plenum, deshalb wird eine Erinnerung raus geschickt!"); - let pad_content = download_and_return_pad(current_pad_link.clone()).expect("Fehler beim Download des Pads!"); + let pad_content = download_and_return_pad(current_pad_link.clone()).await.expect("Fehler beim Download des Pads!"); let pad_content_without_top_instructions = try_to_remove_top_instructions(pad_content); println!("Pad-content geladen!"); if number_of_tops(&pad_content_without_top_instructions) == 0 { @@ -182,8 +210,8 @@ fn main() -> Result<(), Box> { println!("message id: {}", message_id) } else if yesterday_was_plenum { // This logic breaks on 02/2034, but on every other month it works - let old_pad_content = download_and_return_pad(format!("{}/download", current_pad_link.clone())).expect("Fehler beim Download des Pads!"); - // MUSS WIEDER REIN NACH DEM TESTEN: generate_new_pad_for_following_date(übernächster_plenumtermin, überübernächster_plenumtermin, &config).expect("Fehler! Plenumspad konnte nicht generiert werden!"); + let old_pad_content = download_and_return_pad(format!("{}/download", current_pad_link.clone())).await.expect("Fehler beim Download des Pads!"); + // MUSS WIEDER REIN NACH DEM TESTEN: generate_new_pad_for_following_date(übernächster_plenumtermin, überübernächster_plenumtermin, &config).await.expect("Fehler! Plenumspad konnte nicht generiert werden!"); println!("DATENBANK: aktuelles-plenumspad: {:?} und zukünftiges plenumspad: {:?}", &config.get("aktuelles-plenumspad"), &config.get("zukünftiges-plenumspad")); let old_pad_content_without_top_instructions = try_to_remove_top_instructions(old_pad_content); @@ -206,10 +234,12 @@ fn main() -> Result<(), Box> { } -fn download_and_return_pad(pad_link: String) -> Result> { +async fn download_and_return_pad(pad_link: String) -> Result> { //https://md.berlin.ccc.de/OlizTqsSQEeqil0OZmo-Qw/download - let pad_content: String = reqwest::blocking::get(pad_link)? - .text()?; + let pad_content: String = reqwest::get(pad_link) + .await? + .text() + .await?; //println!("{}", pad_content); Ok(pad_content) } @@ -302,17 +332,17 @@ fn mail_versenden(inhalt: String, betreff: String) -> std::result::Result Result<(), Box> { let client = Client::new(); - match create_new_pads::create_pad(&client, HEDGEDOC_SERVER_URL) { + match create_new_pads::create_pad(&client, HEDGEDOC_SERVER_URL).await { Ok(pad_url) => { println!("Pad created successfully at URL: {}", pad_url); // Get the most recent plenum template and replace the placeholders: - let template_from_pad = download_and_return_pad(PLENUM_TEMPLATE_URL.to_string()); // Download Pad + let template_from_pad = download_and_return_pad(PLENUM_TEMPLATE_URL.to_string()).await; // Download Pad let template_content: String = template_from_pad.unwrap_or_else(|error| FALLBACK_TEMPLATE.to_string()); // If Download wasn't successful, use offline Template let template_modified: String = replace_placeholders(&template_content, übernächster_plenumtermin, überübernächster_plenumtermin).unwrap_or_else(|error |template_content); // Try regex, if not successful use without regex let future_pad_id: &str = pad_url.trim_start_matches(&format!("{}/", HEDGEDOC_SERVER_URL)); - match create_new_pads::import_note(&client, template_modified, Some(future_pad_id), HEDGEDOC_SERVER_URL) { + match create_new_pads::import_note(&client, template_modified, Some(future_pad_id), HEDGEDOC_SERVER_URL).await { Ok(_) => { println!("Pad updated successfully with template content."); rotate (future_pad_id, kv); @@ -349,53 +379,27 @@ fn try_to_remove_top_instructions (pad_content: String) -> String { result.to_string() // Wenn es nicht geklappt hat, wird einfach das Pad mit dem Kommentar zurückgegeben } + + fn pad_ins_wiki(old_pad_content: String) { - convert_markdown_to_mediawiki_and_save_as_txt(old_pad_content); - - // Textdatei wieder einlesen - let mut file = File::open("pandoc-output.txt").expect("Fehler beim öffnen der MediaWiki-Textdatei!"); - let mut contents = String::new(); - file.read_to_string(&mut contents).expect("Fehler beim auslesen der MediaWiki-Textdatei!"); - - // Passwörter aus Datenbank lesen (ToBeDone) - let plenum_bot_user = String::from("PlenumBot@PlenumBot-PW1"); - let plenum_bot_pw = String::from("**OLD_API_PW_REMOVED**"); - let login_token = login_to_mediawiki(plenum_bot_user.clone(), plenum_bot_pw.clone()).expect("Fehler beim Einloggen!"); - println!("plenum_bot_user: {plenum_bot_user}, plenum_bot_pw: {plenum_bot_pw}, login_token: {login_token}") -} - -fn convert_markdown_to_mediawiki_and_save_as_txt (old_pad_content: String) { //Convert Markdown into Mediawiki // Vanilla pandoc Befehl: pandoc --from markdown --to mediawiki --no-highlight let mut p = pandoc::new(); p.set_input(pandoc::InputKind::Pipe(old_pad_content)); p.set_input_format(pandoc::InputFormat::Markdown, vec![]); + // p.set_output_format(Pandoc::OutputFormat::mediawiki, vec![MarkdownExtension::Smart]); p.set_output(pandoc::OutputKind::File("./pandoc-output.txt".parse().unwrap())); p.set_output_format(pandoc::OutputFormat::MediaWiki, vec![]); p.execute().expect("Fehler beim Umwandeln des und speichern des Pads in eine mediawiki-Textdatei"); -} - -fn login_to_mediawiki (plenum_bot_user: String, plenum_bot_pw: String) -> Result> { - //let mut map = HashMap::new(); - //map.insert("logintoken", "result"); - let username = "cccb-wiki"; - let password = "**OLD_PW_REMOVED**"; - let auth_header_value = format!("{}:{}", username, password); - // let auth_header_value = format!("Basic {}", Engine::encode(&auth_value, ())); - let client = reqwest::blocking::Client::new(); - let resp = client - .get("https://wiki.berlin.ccc.de/api.php?action=query&meta=tokens&type=login&format=json") - .send()? - .text()?; - //let response = client - // .post("https://wiki.berlin.ccc.de/api.php?action=query&meta=tokens&type=login&format=json") - // .form(&[("Username", "cccb-wiki"), ("Password", "**OLD_PW_REMOVED**")]) - // .send() - // .unwrap(); - //.json(&map); - let html_source = resp.text()?; - //let login_token: String = map.get("logintoken").unwrap().to_string().clone(); - println!("---HTML:---\n{}\n-----------", html_source); - Ok(String::from("unimplemented")) -} \ No newline at end of file + // Textdatei wieder einlesen + let mut file = File::open("pandoc-output.txt").expect("Fehler beim öffnen der MediaWiki-Textdatei!"); + let mut contents = String::new(); + file.read_to_string(&mut contents).expect("Fehler beim auslesen der MediaWiki-Textdatei!"); + + // Passwörter aus Datenbank lesen (ToBeDone) + let plenum_bot_user = String::from("PlenumBot@PlenumBot-PW1"); + let plenum_bot_pw = String::from("**OLD_API_PW_REMOVED**"); + + +}