split config & move email out into own file

In preparation for separate programs, the config spec is now built in
main; each module defines its own group and main combines them all.
This commit is contained in:
nobody 2024-08-05 04:45:34 +02:00 committed by murmeldin
parent 155bab8e5e
commit 0a992e2900
5 changed files with 234 additions and 142 deletions

View file

@ -13,65 +13,9 @@ const ANSI_FIELD: &str = "\x1b[1;33m";
const ANSI_NOTICE: &str = "\x1b[33m";
const ANSI_RESET: &str = "\x1b[0m";
const CONFIG_SPEC: CfgSpec<'static> = 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 <plenum-bot@berlin.ccc.de>", description: "Email address to use for \"From:\"." },
CfgField::Default { key: "to", default: "CCCB Intern <intern@berlin.ccc.de>", 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)." },
],
},
CfgGroup { name: "wiki",
description: "API Settings for Mediawiki",
fields: &[
CfgField::Default { key: "server-url", default: "https://wiki.berlin.ccc.de/" , description: "Server running the wiki." },
CfgField::Default { key: "http-user", default: "cccb-wiki", description: "HTTP basic auth user name." },
CfgField::Password { key: "http-pass", description: "HTTP basic auth password." },
CfgField::Default { key: "api-user", default: "PlenumBot@PlenumBot-PW1", description: "API Username associated with the bot account used for edits." },
CfgField::Password { key: "api-secret", description: "API secret / \"password\" used for authenticating as the bot." },
],
},
// TODO: Matrix,
CfgGroup { name: "text",
description: "Various strings used.",
fields: &[
CfgField::Default { key: "email-greeting",
default: "Hallo liebe Mitreisende,",
description: "\"Hello\"-greeting added at the start of every email."
},
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 {
pub fn populate_defaults(spec: &CfgSpec, config: &KV) {
for group in spec.groups {
for field in group.fields {
match field {
CfgField::Silent { default, .. } => {
@ -89,9 +33,9 @@ pub fn populate_defaults(config: &KV) {
/// Check all groups / fields and interactively edit as needed.
///
/// Will report if the config is fine or has missing values.
pub fn interactive_check(config: KV) -> Result<(), Box<dyn Error>> {
pub fn interactive_check(spec: &CfgSpec, config: KV) -> Result<(), Box<dyn Error>> {
let mut all_valid: bool = true;
for group in CONFIG_SPEC.groups {
for group in spec.groups {
group_header(group.name, group.description);
let todo = needs_info(&config, group);
// TODO: add distinction between edit all / edit necessary only
@ -306,7 +250,7 @@ fn prompt_password() -> String {
/* ***** config structure definition ***** */
/// Describes a field in the configuration.
enum CfgField<'a> {
pub 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.
@ -326,17 +270,17 @@ enum CfgField<'a> {
/// 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>],
pub struct CfgGroup<'a> {
pub name: &'a str,
pub description: &'a str,
pub 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>],
pub struct CfgSpec<'a> {
pub groups: &'a [CfgGroup<'a>],
}
impl<'a> CfgField<'a> {

111
src/email.rs Normal file
View file

@ -0,0 +1,111 @@
use crate::config_spec::{CfgField, CfgGroup};
use lettre::message::{header, SinglePart};
use lettre::transport::smtp::authentication::Credentials;
use lettre::{Message, SmtpTransport, Transport};
use std::error::Error;
use uuid::Uuid;
pub const CONFIG: CfgGroup<'static> = 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 <plenum-bot@berlin.ccc.de>",
description: "Email address to use for \"From:\".",
},
CfgField::Default {
key: "to",
default: "CCCB Intern <intern@berlin.ccc.de>",
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).",
},
],
};
#[derive(Clone)]
pub struct Email {
server: String,
credentials: Credentials,
message_id_suffix: String,
is_dry_run: bool,
}
pub struct SimpleEmail {
base: Email,
from: String,
to: String,
in_reply_to: Option<String>,
}
impl SimpleEmail {
pub fn new(base: Email, from: &str, to: &str, in_reply_to: Option<String>) -> Self {
Self { base: base.clone(), from: from.to_string(), to: to.to_string(), in_reply_to }
}
pub fn send_email(&self, subject: String, body: String) -> Result<String, Box<dyn Error>> {
self.base.send_email(
self.from.clone(),
self.to.clone(),
subject,
body,
self.in_reply_to.clone(),
)
}
}
impl Email {
pub fn new(server: &str, user: &str, pass: &str, is_dry_run: bool) -> Self {
let message_id_suffix = user.split('@').next_back().unwrap_or(&server).to_string();
let credentials = Credentials::new(user.to_string(), pass.to_string());
Self { server: server.to_string(), credentials, message_id_suffix, is_dry_run }
}
pub fn send_email(
&self, from: String, to: String, subject: String, body: String, in_reply_to: Option<String>,
) -> Result<String, Box<dyn Error>> {
let message_id = Uuid::new_v4().to_string() + &self.message_id_suffix;
let email = Message::builder()
.from(from.parse().unwrap())
.to(to.parse().unwrap())
.message_id(Some(message_id.clone()));
let email =
if in_reply_to.is_some() { email.in_reply_to(in_reply_to.unwrap()) } else { email }
.subject(subject)
.singlepart(
SinglePart::builder().header(header::ContentType::TEXT_PLAIN).body(body),
)
.unwrap();
if !self.is_dry_run {
let mailer = SmtpTransport::starttls_relay(&self.server)?
.credentials(self.credentials.clone())
.build();
mailer.send(&email)?;
Ok(message_id)
} else {
println!(
"[DRY RUN - NOT sending email]\n(raw message:)\n{}",
std::str::from_utf8(&email.formatted()).unwrap_or("((UTF-8 error))")
);
Ok("dummy-message-id@localhost".to_string())
}
}
}

View file

@ -1,8 +1,28 @@
use crate::config_spec::{CfgField, CfgGroup};
use reqwest::blocking::Client;
use reqwest::blocking::Response;
use std::error::Error;
// TODO: implement dry-run logic
pub const CONFIG: CfgGroup<'static> = 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." },
],
};
pub struct HedgeDoc {
server_url: String,
is_dry_run: bool,
@ -35,6 +55,7 @@ impl HedgeDoc {
}
pub fn create_pad(&self) -> Result<String, Box<dyn Error>> {
if self.is_dry_run { todo!("NYI: sane dry-run behavior") }
let res = self.do_request(&format!("{}/new", self.server_url)).unwrap();
if res.status().is_success() {
Ok(self.get_id_from_response(res))
@ -44,6 +65,7 @@ impl HedgeDoc {
}
pub fn import_note(&self, id: Option<&str>, content: String) -> Result<String, Box<dyn Error>> {
if self.is_dry_run { todo!("NYI: sane dry-run behavior") }
let url = match id {
Some(id) => self.format_url(&format!("new/{id}")),
None => self.format_url("new"),

View file

@ -34,33 +34,58 @@ future improvements:
*/
// Import other .rs files as modules
mod key_value;
use email::{Email, SimpleEmail};
use key_value::KeyValueStore as KV;
mod config_spec;
use config_spec::{CfgField, CfgGroup, CfgSpec};
mod email;
mod hedgedoc;
use hedgedoc::HedgeDoc;
mod mediawiki;
mod config_check;
pub mod variables_and_settings;
use std::borrow::Cow;
use std::env;
use std::error::Error;
use std::fs::File;
use std::io::Read;
// use std::future::Future;
use chrono::{Datelike, Local, NaiveDate, Weekday};
use clap::{Arg, Command};
use regex::Regex;
use uuid::Uuid;
use lettre::message::{header, SinglePart};
use lettre::transport::smtp::authentication::Credentials;
use lettre::{Message, SmtpTransport, Transport};
use reqwest::blocking::Client;
const FALLBACK_TEMPLATE: &str = variables_and_settings::FALLBACK_TEMPLATE;
/* ***** Config Spec ***** */
const CONFIG_SPEC: CfgSpec<'static> = CfgSpec {
groups: &[
CfgGroup { name: "config",
description: "(internal values)",
fields: &[
CfgField::Silent { key: "version", default: "1" },
],
},
hedgedoc::CONFIG,
mediawiki::CONFIG,
email::CONFIG,
// TODO: Matrix, …?
CfgGroup { name: "text",
description: "Various strings used.",
fields: &[
CfgField::Default { key: "email-greeting",
default: "Hallo liebe Mitreisende,",
description: "\"Hello\"-greeting added at the start of every email."
},
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."
},
],
},
],
};
/* ***** Runtime Configuration (Arguments & Environment Variables) ***** */
/// Checks environment variable `DRY_RUN` to see if any external operations
@ -109,14 +134,26 @@ fn main() -> Result<(), Box<dyn Error>> {
let args = parse_args();
let config_file = args.config_file.as_str();
let config = KV::new(config_file).unwrap();
config_check::populate_defaults(&config);
config_spec::populate_defaults(&CONFIG_SPEC, &config);
if args.check_mode {
return config_check::interactive_check(config);
return config_spec::interactive_check(&CONFIG_SPEC, config);
}
// config
let hedgedoc = HedgeDoc::new(&config["hedgedoc-server-url"], is_dry_run());
let email_ = Email::new(
&config["email-server"],
&config["email-user"],
&config["email-password"],
is_dry_run(),
);
let email = SimpleEmail::new(
email_,
&config["email-from"],
&config["email-to"],
config.get("email-in-reply-to").ok(),
);
println!("[START]\nAktueller Zustand der DB:");
config.dump_redacting(&["email-password", "wiki-password", "matrix-password"]).ok();
config.dump_redacting(&["email-password", "wiki-http-password", "wiki-api-secret", "matrix-password"]).ok();
// Dienstage diesen Monat
let all_tuesdays: Vec<NaiveDate> = get_all_weekdays(0, Weekday::Tue);
@ -213,9 +250,8 @@ Bis morgen, 20 Uhr!
{email_signature}"#
); // ADJ_TIMEYWIMEY
println!("---E-Mail:---\n{}\n-----------", message);
// XXX option x expect
message_id = Some(mail_versenden(&config, betreff, message).expect("Plenum findet statt. Mail wurde versucht zu senden, konnte aber nicht gesendet werden!"))
message_id = email.send_email(betreff, message).ok()
// .expect("Plenum findet statt. Mail wurde versucht zu senden, konnte aber nicht gesendet werden!")
} else {
// Mail an alle senden und absagen
let betreff = format!("Plenum am {} fällt mangels Themen aus", nächster_plenumtermin);
@ -232,9 +268,8 @@ Bis zum nächsten Plenum.
{email_signature}"#,
nächster_plenumtermin
);
println!("---E-Mail:---\n{}\n-----------", message);
// XXX option x expect
message_id = Some(mail_versenden(&config, betreff, message).expect("Plenum wird abgesagt. Mail wurde versucht zu senden, konnte aber nicht gesendet werden!"))
message_id = email.send_email(betreff, message).ok()
// .expect("Plenum wird abgesagt. Mail wurde versucht zu senden, konnte aber nicht gesendet werden!"))
}
} else if in_3_days_is_plenum {
println!("In 3 Tagen ist Plenum, deshalb wird eine Erinnerung raus geschickt!");
@ -251,9 +286,8 @@ Hier ist der Link zum Pad, wo ihr noch Themen eintragen könnt:
{email_signature}"#
);
println!("---E-Mail:---\n{}\n-----------", message);
// XXX option x expect
message_id = Some(mail_versenden(&config, betreff, message).expect("Noch nicht genug Themen. Mail wurde versucht zu senden, konnte aber nicht gesendet werden!"))
message_id = email.send_email(betreff, message).ok()
// .expect("Noch nicht genug Themen. Mail wurde versucht zu senden, konnte aber nicht gesendet werden!"))
}
} else if yesterday_was_plenum {
// This logic breaks on 02/2034, but on every other month it works
@ -290,15 +324,15 @@ Und hier ist das Protokoll des letzten Plenums:
);
let betreff: String =
format!("Plenumsprotokoll vom {}: Es gab {} TOPs", nächster_plenumtermin, top_anzahl);
println!("---E-Mail:---\n{}\n-----------", message);
// XXX option x expect
message_id = Some(mail_versenden(&config, betreff, message).expect("Mail mit Plenumsprotokoll wurde versucht zu senden, konnte aber nicht gesendet werden!"));
message_id = email.send_email(betreff, message).ok();
// .expect("Mail mit Plenumsprotokoll wurde versucht zu senden, konnte aber nicht gesendet werden!"));
mediawiki::pad_ins_wiki(old_pad_content_without_top_instructions);
}
println!("message id: {:?}", message_id);
println!("[ENDE]\nAktueller Zustand der DB:");
config.dump_redacting(&["email-password", "matrix-password", "wiki-password"]).ok();
config.dump_redacting(&["email-password", "wiki-http-password", "wiki-api-secret", "matrix-password"]).ok();
if config.has_errors() {
Err("There were errors.".into())
@ -350,52 +384,6 @@ fn create_tldr(pad_content: &String) -> Vec<&str> {
//tldr_vec.append() = m.iter()
}
fn mail_versenden(
config: &KV, betreff: String, inhalt: String,
) -> std::result::Result<String, Box<dyn std::error::Error>> {
// Define the email
let message_id: String = Uuid::new_v4().to_string() + &String::from("@berlin.ccc.de");
let mail_to: &str = &config["email-to"];
let email = Message::builder()
// Set the sender's name and email address
.from(config["email-sender"].parse().unwrap())
// Set the recipient's name and email address
.to(mail_to.parse().unwrap())
.message_id(Option::from(message_id.clone()))
.in_reply_to("19de3985-05b4-42f3-9a6a-e941479be2ed@berlin.ccc.de".to_string())
// Set the subject of the email
.subject(betreff)
.singlepart(SinglePart::builder().header(header::ContentType::TEXT_PLAIN).body(inhalt))
.unwrap();
if !is_dry_run() {
// Set up the SMTP client
let creds = Credentials::new(
config["email-user"].to_string(),
config["email-password"].to_string(),
);
// Open a remote connection to gmail
let mailer =
SmtpTransport::starttls_relay(&config["email-server"])?.credentials(creds).build();
// Send the email
match mailer.send(&email) {
Ok(_) => println!("Email sent successfully!"),
Err(e) => eprintln!("Could not send email: {:?}", e),
}
Ok(message_id)
} else {
println!(
"[DRY RUN - NOT] sending email\n(raw message:)\n{}",
std::str::from_utf8(&email.formatted()).unwrap_or("((UTF-8 error))")
);
Ok("dummy-message-id@localhost".to_string())
}
}
fn generate_new_pad_for_following_date(
config: KV, hedgedoc: HedgeDoc, übernächster_plenumtermin: &String,
überübernächster_plenumtermin: &String, kv: &KV,

View file

@ -1,12 +1,39 @@
use std::error::Error;
use std::fs::File;
use std::io::Read;
use clap::builder::Str;
use crate::config_spec::{CfgField, CfgGroup};
use pandoc::{PandocError, PandocOutput};
use reqwest;
use reqwest::blocking::Client;
use reqwest::tls;
use serde::Deserialize;
use std::error::Error;
use std::fs::File;
use std::io::Read;
pub const CONFIG: CfgGroup<'static> = CfgGroup {
name: "wiki",
description: "API Settings for Mediawiki",
fields: &[
CfgField::Default {
key: "server-url",
default: "https://wiki.berlin.ccc.de/",
description: "Server running the wiki.",
},
CfgField::Default {
key: "http-user",
default: "cccb-wiki",
description: "HTTP basic auth user name.",
},
CfgField::Password { key: "http-password", description: "HTTP basic auth password." },
CfgField::Default {
key: "api-user",
default: "PlenumBot@PlenumBot-PW1",
description: "API Username associated with the bot account used for edits.",
},
CfgField::Password {
key: "api-secret",
description: "API secret / \"password\" used for authenticating as the bot.",
},
],
};
#[derive(Deserialize)]
struct QueryResponse {
@ -76,4 +103,4 @@ pub fn get_login_token(client: &Client, http_user: &str, http_pass: &String) ->
.text()?;
let response_deserialized: QueryResponse = serde_json::from_str(&resp)?;
Ok(response_deserialized.query.tokens.logintoken)
}
}