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:
parent
155bab8e5e
commit
0a992e2900
5 changed files with 234 additions and 142 deletions
369
src/config_spec.rs
Normal file
369
src/config_spec.rs
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
use crate::key_value::KeyValueStore as KV;
|
||||
use std::error::Error;
|
||||
use std::io::{self, Write};
|
||||
|
||||
/// Text shown for an empty field.
|
||||
const EMPTY_VALUE: &str = "(empty)";
|
||||
/// Text shown in place of a password.
|
||||
const HIDDEN_PASSWORD: &str = "*****";
|
||||
|
||||
// highlight some of the info
|
||||
const ANSI_GROUP: &str = "\x1b[1;31m";
|
||||
const ANSI_FIELD: &str = "\x1b[1;33m";
|
||||
const ANSI_NOTICE: &str = "\x1b[33m";
|
||||
const ANSI_RESET: &str = "\x1b[0m";
|
||||
|
||||
/// Ensure all fields with known default values exist.
|
||||
pub fn populate_defaults(spec: &CfgSpec, config: &KV) {
|
||||
for group in spec.groups {
|
||||
for field in group.fields {
|
||||
match field {
|
||||
CfgField::Silent { default, .. } => {
|
||||
config.default(&field.full_key(group.name), default)
|
||||
},
|
||||
CfgField::Default { default, .. } => {
|
||||
config.default(&field.full_key(group.name), default)
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check all groups / fields and interactively edit as needed.
|
||||
///
|
||||
/// Will report if the config is fine or has missing values.
|
||||
pub fn interactive_check(spec: &CfgSpec, config: KV) -> Result<(), Box<dyn Error>> {
|
||||
let mut all_valid: bool = true;
|
||||
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
|
||||
let choices = if !todo.is_empty() {
|
||||
println!(
|
||||
"{}The following fields need adjustment: {}{}",
|
||||
ANSI_NOTICE,
|
||||
todo.join(", "),
|
||||
ANSI_RESET
|
||||
);
|
||||
&["[E]dit (default)", "[L]ist all", "[S]kip group"]
|
||||
} else {
|
||||
println!(
|
||||
"{}This group looks fine. OK to [S]kip, unless you want to adjust values here.{}",
|
||||
ANSI_NOTICE, ANSI_RESET
|
||||
);
|
||||
&["[S]kip group (default)", "[E]dit", "[L]ist all"]
|
||||
};
|
||||
loop {
|
||||
let choice = prompt_action(choices);
|
||||
match choice {
|
||||
'L' => {
|
||||
show_group(&config, group);
|
||||
},
|
||||
'E' => {
|
||||
for field in group.fields {
|
||||
check_field(&config, group.name, field, &mut all_valid)?;
|
||||
}
|
||||
break;
|
||||
},
|
||||
'S' => {
|
||||
if !todo.is_empty() {
|
||||
all_valid = false;
|
||||
}
|
||||
break;
|
||||
},
|
||||
_ => {
|
||||
unreachable!();
|
||||
}, // (prompt already checks)
|
||||
}
|
||||
}
|
||||
}
|
||||
if all_valid {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("WARNING: Config is incomplete. Re-run to fix.".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn check_field(
|
||||
config: &KV, grpname: &str, field: &CfgField, ok: &mut bool,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
if field.is_silent() {
|
||||
return Ok(());
|
||||
}
|
||||
show_field(config, grpname, field);
|
||||
let key = field.full_key(grpname);
|
||||
let value = config.get(&key).ok();
|
||||
let mut actions: Vec<&'static str> = Vec::new();
|
||||
// TODO: adjust order: empty password should offer input first
|
||||
// TODO: RandomId should offer generating as [R]egenerate
|
||||
if value.is_some() | field.is_password() | field.is_optional() {
|
||||
actions.push("[K]eep as-is");
|
||||
}
|
||||
if field.default_description().is_some() {
|
||||
actions.push("[R]eset to default");
|
||||
}
|
||||
if field.is_password() {
|
||||
actions.push("[R]emove");
|
||||
}
|
||||
actions.push("[I]nput new value");
|
||||
if !field.is_password() {
|
||||
actions.push("[L]ong (multiline) input")
|
||||
};
|
||||
|
||||
match prompt_action(&actions) {
|
||||
'K' => {
|
||||
// we allow leaving a password empty, but that means config is incomplete
|
||||
if value.is_none() & !field.is_optional() {
|
||||
*ok &= false;
|
||||
}
|
||||
},
|
||||
'R' => {
|
||||
match field.default_value()? {
|
||||
Some(value) => {
|
||||
config.set(&key, &value)?;
|
||||
},
|
||||
// password again
|
||||
None => {
|
||||
config.delete(&key)?;
|
||||
*ok &= false;
|
||||
},
|
||||
}
|
||||
},
|
||||
'I' => {
|
||||
let value = if field.is_password() { prompt_password() } else { prompt_single_line() };
|
||||
config.set(&key, &value)?;
|
||||
},
|
||||
'L' => {
|
||||
let value = prompt_multiline();
|
||||
config.set(&key, &value)?;
|
||||
},
|
||||
_ => {
|
||||
return Err("Wat.".into());
|
||||
},
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/* ***** displaying various kinds of info ***** */
|
||||
|
||||
fn group_header(name: &str, description: &str) {
|
||||
println!("==============================");
|
||||
println!("{}{}{} - {}", ANSI_GROUP, name, ANSI_RESET, description);
|
||||
println!("");
|
||||
}
|
||||
|
||||
fn show_group(config: &KV, group: &CfgGroup) {
|
||||
for field in group.fields {
|
||||
show_field(&config, group.name, field);
|
||||
}
|
||||
}
|
||||
|
||||
fn show_field(config: &KV, grpname: &str, field: &CfgField) {
|
||||
if field.is_silent() {
|
||||
return;
|
||||
}
|
||||
let key = field.full_key(grpname);
|
||||
println!("{}{}{} - {}", ANSI_FIELD, &key, ANSI_RESET, field.description());
|
||||
println!(" default: {}", field.default_description().unwrap_or(EMPTY_VALUE.to_string()));
|
||||
let value = config
|
||||
.get(&key)
|
||||
.ok()
|
||||
.map(|s| if field.is_password() { HIDDEN_PASSWORD.to_string() } else { s })
|
||||
.unwrap_or(EMPTY_VALUE.to_string());
|
||||
println!(" current: {}", value);
|
||||
}
|
||||
|
||||
/* ***** basic validation ***** */
|
||||
|
||||
fn needs_info(config: &KV, group: &CfgGroup) -> Vec<String> {
|
||||
let mut acc = Vec::new();
|
||||
for field in group.fields {
|
||||
if field.is_action_required(config, group.name) {
|
||||
acc.push(field.key());
|
||||
}
|
||||
}
|
||||
acc
|
||||
}
|
||||
|
||||
/* ***** prompting (with actual I/O) ***** */
|
||||
|
||||
/// Ask for a choice between several options. First option is the default,
|
||||
/// format options with the first character in brackets like so: `"[E]xample"`.
|
||||
fn prompt_action(choices: &[&str]) -> char {
|
||||
let prompt_message = choices.join(", ");
|
||||
let default_choice = choices[0].to_uppercase().chars().nth(1).unwrap_or_default();
|
||||
loop {
|
||||
println!("{}", prompt_message);
|
||||
print!("Select: ");
|
||||
io::stdout().flush().ok();
|
||||
|
||||
// Read line and take first non-space character
|
||||
let mut input = String::new();
|
||||
io::stdin().read_line(&mut input).unwrap();
|
||||
let input = input.trim().to_uppercase().chars().next().unwrap_or(default_choice);
|
||||
|
||||
// Check list of choices for match
|
||||
if choices
|
||||
.iter()
|
||||
.any(|&choice| choice.to_uppercase().chars().nth(1).unwrap_or_default() == input)
|
||||
{
|
||||
return input;
|
||||
} else {
|
||||
println!("Invalid choice. Try again!");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a single line of text.
|
||||
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()
|
||||
}
|
||||
|
||||
/// Read multiple lines of text, terminated by a line containing just a '.'.
|
||||
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
|
||||
}
|
||||
|
||||
/// Read a password without echoing it.
|
||||
fn prompt_password() -> String {
|
||||
let pass = rpassword::prompt_password("New password (not shown) : ").unwrap();
|
||||
// disabled echo means the newline also isn't shown
|
||||
println!("");
|
||||
pass
|
||||
}
|
||||
|
||||
/* ***** config structure definition ***** */
|
||||
|
||||
/// Describes a field in the configuration.
|
||||
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.
|
||||
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() -> Result<String, Box<dyn Error>>,
|
||||
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}`.
|
||||
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.
|
||||
pub struct CfgSpec<'a> {
|
||||
pub groups: &'a [CfgGroup<'a>],
|
||||
}
|
||||
|
||||
impl<'a> CfgField<'a> {
|
||||
/// Silent fields don't get prompted or shown.
|
||||
fn is_silent(&self) -> bool {
|
||||
matches!(self, CfgField::Silent { .. })
|
||||
}
|
||||
|
||||
/// Password fields will be censored when displayed.
|
||||
fn is_password(&self) -> bool {
|
||||
matches!(self, CfgField::Password { .. })
|
||||
}
|
||||
|
||||
/// Optional fields are allowed to be (and stay) empty.
|
||||
fn is_optional(&self) -> bool {
|
||||
matches!(self, CfgField::Optional { .. })
|
||||
}
|
||||
|
||||
/// Reports if the field needs changing or is ok as-is.
|
||||
///
|
||||
/// (Because of the default population at boot, Silent and Default must
|
||||
/// *necessarily* always have a value. Optional is optional. Only
|
||||
/// Password and RandomId might need actions if they are missing.)
|
||||
fn is_action_required(&self, config: &KV, grpname: &str) -> bool {
|
||||
if matches!(
|
||||
self,
|
||||
CfgField::Silent { .. } | CfgField::Default { .. } | CfgField::Optional { .. }
|
||||
) {
|
||||
false
|
||||
} else {
|
||||
config.get(self.full_key(grpname).as_str()).ok().is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets `key`, which is the local part of the field name. (`.full_key` is used for the DB.)
|
||||
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()
|
||||
}
|
||||
|
||||
/// Full field name / key used in the DB (currently `{grpname}-{fieldname}`.)
|
||||
fn full_key(&self, grpname: &str) -> String {
|
||||
format!("{}-{}", grpname, self.key())
|
||||
}
|
||||
|
||||
/// Gets `description` or "(none)".
|
||||
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()
|
||||
}
|
||||
|
||||
/// Gets a description of the default value if one exists.
|
||||
/// (Either the value itself, or a description of the generator making a default value.)
|
||||
fn default_description(&self) -> Option<String> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the actual default value, which may involve running a generator.
|
||||
fn default_value(&self) -> Result<Option<String>, Box<dyn Error>> {
|
||||
match self {
|
||||
CfgField::Silent { default, .. } => Ok(Some(default.to_string())),
|
||||
CfgField::Default { default, .. } => Ok(Some(default.to_string())),
|
||||
CfgField::RandomId { generator, .. } => generator().map(Some),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
111
src/email.rs
Normal file
111
src/email.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
126
src/main.rs
126
src/main.rs
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue